From c89a9ed8ff7c0f73792a3d4163ad37a1ba6f00cc Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Thu, 1 Oct 2026 03:09:14 -0400 Subject: [PATCH 01/28] docs: define media kit publication and explicit picture selection --- ...2026-09-30-media-kit-publication-design.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-30-media-kit-publication-design.md diff --git a/docs/superpowers/specs/2026-09-30-media-kit-publication-design.md b/docs/superpowers/specs/2026-09-30-media-kit-publication-design.md new file mode 100644 index 00000000..1ae34a5b --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-media-kit-publication-design.md @@ -0,0 +1,275 @@ +# Media-kit publication and explicit profile-picture placement + +## Status and intent + +Locked decisions: BASIC approved the product decisions in this chat on +2026-09-30. This written design is awaiting BASIC's review before the +implementation plan. No implementation or production migration is authorized +by the existence of this document. + +Make it straightforward for a trusted contributor to upload and publish a +sourced image through either the website or MCP. Publishing an image adds it +to the public media kit. Choosing it as a profile picture is a separate, +deliberate action. Remove the four declarations and the separate evidence +confirmation command from the publishing journey. + +## Current experience + +Contributor intake requests `profile_image` for a person or `primary_logo` for +a community. Both independent approval and trusted publication apply that +placement when consuming the upload. A primary logo can also become the compact +profile image, so changing a label from picture to logo would not fix the issue. + +Trusted publication requires four stored booleans and an empty identity slot. +The website exposes Confirm evidence, Publish, and Independent review as +competing actions. Source URLs appear as raw text. Broad profile `updatedAt` +checks can invalidate a proposal after an unrelated edit; the submitter cannot +use independent review's rebase command on their own proposal. + +Existing assets already support multiple placements, additive gallery items, +downloads, attribution, visibility checks, capacity, retention, and cleanup. +Reuse those facilities. A second media-storage system is unnecessary. + +The inspected checkout is at `ef6d8f3`, the earlier MCP catalog work. Before +implementation, reconcile this design against current main and current hosted +feature flags. Paths below identify the existing flow, not a release baseline. + +## Locked product rules + +1. Remove the checklist and Confirm evidence. Do not hide the same checklist + behind another screen, require those booleans through MCP, or populate them + automatically as if the publisher made assertions. +2. Upload completion creates a proposal; it does not publish automatically. + A trusted publisher's explicit Publish action makes their own eligible + contribution a public media-kit item. +3. Existing profile pictures do not prevent additive kit publication. +4. Ordinary contributors' proposals still require independent review. + Trusted contributor, trusted publisher, reviewer, owner, and admin authority + remain distinct. Existing independent-review self-review restrictions remain. +5. Direct trusted publication is limited to public, published, unclaimed + profiles. Claimed profiles retain their existing owner-management workflow. +6. Owners and authorized admins can change profile pictures. A trusted publisher + can select their own published kit item into a genuinely empty identity slot + on an unclaimed profile. Existing pictures require independent review for a + contributor-requested replacement. +7. A publisher can undo their own selection while that selection remains theirs + and the profile remains unclaimed. Undoing placement leaves the kit item + published. An intervening owner, admin, or reviewer selection transfers control of + that placement, even when it selects the same asset. +8. Contributors can correct metadata or remove their own published kit-only + items on eligible unclaimed profiles. They cannot use removal to clear an + image selected by another authorized actor or override claimed-profile control. +9. Keep preview, credit, and a readable source link. Source/sharer credit is + valid without a claim of artist authorship. For example, Shared by Karly + must not be rewritten as Created by Karly. +10. Existing restrictions on disputed, rejected, or suppressed material remain. + Removing assertions does not mean that the service can verify real-world + permission or authorship automatically. It checks recorded eligibility and + restrictions and records who performed each action. + +## Proposed journeys + +Website entry paths include the profile's media contribution action, a direct +contribution link, and account media contributions. Authentication returns the +user to their intended contribution rather than to a disconnected account page. + +```mermaid +flowchart TD + Profile[Public profile: contribute media] --> Entry[Contribution form] + Direct[Direct contribution link] --> Entry + Entry --> Auth[Sign in when needed, then return] + Auth --> Upload[Upload with credit and source] + Upload --> Pending[Account media contributions: preview proposal] + Pending -->|Trusted publisher: Publish| Kit[Public media kit and downloads] + Pending -->|Ordinary contributor| Review[Independent review] + Review -->|Approve| Kit + Kit -->|Separate authorized selection| Picture[Profile picture] + Picture -->|Undo own unchanged selection on unclaimed profile| Kit + Kit -->|Correct or remove own kit-only item| Manage[Contribution management] +``` + +The publisher card shows the candidate, attribution, source, and publication +destination. A primary Publish action replaces the checklist and confirmation +step. Independent review remains a secondary route; it must not imply a second +upload. Long source URLs become readable links. Public gallery items need a +display title; reuse the existing label and a deterministic fallback without +forcing an additional approval step. + +Profile-picture selection lives on the published item, not inside Publish. +The user can leave the asset in the kit indefinitely without choosing a primary +placement. Clearing a selection and removing a kit item are distinct actions. + +MCP offers the same sequence: upload, inspect proposal, publish to kit, then +optionally select or clear the primary placement. Corrections and removal must +also be possible through MCP. No browser-only assertion or permission step is +introduced. Existing owner media-management operations remain supported. + +## Domain and command design + +### Assets and publication + +Publish to the existing additive `gallery` placement, preserving metadata and +public media-kit visibility rules. This publication must not create +`profile_image` or `primary_logo`, suppress automatic artwork, or change a +profile's compact identity image. Use existing individual downloads; adding logo +bundles or asset categorization is outside this slice. + +Change intake contracts, stored proposal interpretation, publisher commands, +independent approval, public projections, and clients together. Approval of an +ordinary new image also means publication to the kit. An explicit replacement +request must remain distinguishable from an additive image proposal. + +Record publication method, actor, resource, inspected version, operation ID, +and receipt. Trusted publication does not invent an independent reviewer. +Preserve historical declaration and reviewer records without presenting them +as a requirement of the new workflow. + +### Explicit picture selection + +Selecting a published kit asset adds its primary identity placement while +retaining its gallery placement. Replacing or clearing that primary placement +therefore cannot retire the underlying kit item merely because it lost its +identity use. Keep existing media-capacity accounting consistent with one asset +having multiple placements. + +Empty-slot eligibility follows current rendering semantics, including authored, +legacy, and visible automatic images. This slice does not turn a visible fallback +image into an empty slot or allow trusted publishers to replace it implicitly. + +Record selection actor and a placement revision for new primary actions. +Do not infer who selected legacy placements. Undo checks the exact current +selection, current claim/authority, and expected placement version. An intervening +selection invalidates an older undo request. + +A contributor-requested replacement references the already published asset and +uses independent review. Its approval applies the placement change, not a second +asset publication: no re-upload, duplicate blob, or duplicate published charge. +Extend the existing review lifecycle with an explicit placement-change request +rather than treating an already consumed upload as new media. Existing owner +and admin management stays available under its existing authority. + +### Metadata correction and removal + +Reuse the existing asset-management machinery with bounded contributor +authorization. Check provenance and current state on every command. Permission +to manage a contribution does not grant general profile editing or management +of another contributor's assets. + +Version metadata corrections and audit the actor and changed attribution/source. +Do not rewrite historical publication evidence. Removal follows existing logical +retirement, retention, and cleanup; it is not immediate permanent deletion. +Active primary use or protected selection blocks contributor removal. A publisher +may first undo their own eligible placement, then remove their kit-only item. + +### Freshness and retries + +Use a relevant publication snapshot instead of broad profile `updatedAt` alone. +Bind it to target identity, type, claim and public state, candidate digest and +metadata, and applicable restrictions. Recheck grants, verified email, visibility, +capacity, and authority in the committing transaction. Picture actions additionally +bind the exact placement and its selection revision. + +Unrelated biography, tag, or headline edits do not force another upload. +A meaningful change returns a conflict and a fresh inspectable proposal; the +caller can make a new decision against its current version. Do not silently +retry a changed picture selection or a changed identity. Existing stored bytes +remain available through the ordinary proposal lifecycle. + +Commands retain actor-scoped durable idempotency keys and receipts. A lost response +is recovered by replaying the same key, not by generating a new operation. +Removing the declaration step must not weaken those guarantees. + +### MCP compatibility + +Keep the publication tool as the explicit kit-publication command and retain +its expected-version/idempotency contract. Add or reuse bounded placement and +own-contribution management tools with the same server-side authority as the +website. Publishing authority does not grant `assets:write` owner authority or +independent-review scope. + +Retire the declaration tool from the advertised workflow. A compatibility handler +can return historical receipts or a terminal retired-command result for old +calls, but new publication never requires declaration. Update catalog fixtures, +OAuth tool/scope mapping, documentation, and hosted clients together. Never +reinterpret an old declaration receipt as a newly approved placement action. + +## Existing records and rollout + +Pending image contributions become additive kit proposals without losing their +stored candidate, credit, source, contributor identity, or retention state. +Changing proposal intent changes its review version. Old inspected commands +must refuse stale versions; users inspect again without another upload. + +Preserve existing deliberately selected primary images. Do not guess that all +legacy placements were deliberate or that all trusted publications were mistakes. +Preserve existing visibility. Already public contribution assets must retain a +kit reference before later primary changes can retire them. Do not make private +owner assets public as a side effect of conversion. + +Karly is the explicit correction approved in this design: keep the published +logo, add its kit placement if needed, and clear the automatically assigned +primary placement. A migration dry run must identify the exact current asset and +placement and refuse the correction if another actor has changed that selection +or the target has changed authority. No production action occurs during planning. + +Verify the media-kit feature gate and public field visibility before rollout so +Publish cannot report success while the expected kit is unavailable. Preserve +owner privacy settings. Coordinate backend, MCP, and website versions; do not +deploy clients that still require removed declarations or implicitly select +primary placements. Produce a bounded migration report and operation receipts. + +## Separate follow-up: proposals to claimed profiles + +Locked future direction, outside this implementation: + +```mermaid +flowchart LR + Contributor[Contributor proposes update] --> Notify[Notify profile owner] + Notify --> Compare[Owner compares current and proposed] + Compare -->|Accept| Apply[Apply update] + Compare -->|Decline| Unchanged[Leave profile unchanged] +``` + +All contributors, including trusted publishers, need the claimed profile owner's +approval. Store the uploaded candidate and complete proposal so the owner does +not have to upload it again. The owner is the sole proposal reviewer. Do not add +a preliminary reviewer queue in this slice. If screening proves necessary later, +use reviewer authority rather than automatically giving it to trusted contributors. +Notifications, delivery channels, and generalized profile-field proposals belong +to that follow-up design. + +## Implementation boundaries and verification + +Existing seams: `convex/_profileAssets.ts`, `profileAssets.ts`, +`profileMediaSubmissions.ts`, `_trustedPublication.ts`, `_mediaReview.ts`, the +API contracts, MCP registration/scope map, contribution forms, publication cards, +review UI, and public media-kit rendering. Use the current main versions when +writing the implementation plan. + +Deliver one PR with internal phases for backend/contracts, website/MCP, and +migration/hosted verification. Use an implementer and an independent reviewer +for each implementation round. Finish required checks and the existing 30-minute +post-commit review window before declaring a future PR merge-ready. + +Acceptance evidence must cover: + +- Trusted kit publication without declarations, with an existing profile picture + untouched, through both website and authenticated MCP. +- Ordinary independent approval producing the same additive kit behavior; + self-review and claimed-profile direct-publication refusals remain enforced. +- Explicit empty-slot selection, eligible undo, intervening-selection conflict, + independent replacement, and asset retention after losing primary placement. +- Own-item metadata correction and removal; refusal for another contributor's + item, protected primary use, revoked authority, or a newly claimed target. +- Unrelated profile edits succeeding without re-upload; changed identity, + candidate, visibility, claim, restrictions, or placement producing a conflict. +- Durable same-key retry after uncertain publication or placement outcomes. +- Public kit/download rendering, readable source/credit, migration dry runs, + old-client compatibility, and preservation of historical receipts. + +Use targeted backend/contract tests for these boundaries, browser screenshots +with visual review on desktop and mobile, and actual authenticated hosted MCP +tool calls. A catalog listing, OAuth login, local fixture, or backend receipt +alone does not prove the full website/MCP journey. Reuse obvious utility labels +and existing approved copy; submit any substantive new public prose for BASIC's +exact-copy review before shipping. From 75f61cd4a31e46e9ccc3a92f66578228a7db482d Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Thu, 1 Oct 2026 03:20:16 -0400 Subject: [PATCH 02/28] docs: plan media kit publication workflow --- .../plans/2026-10-01-media-kit-publication.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-media-kit-publication.md diff --git a/docs/superpowers/plans/2026-10-01-media-kit-publication.md b/docs/superpowers/plans/2026-10-01-media-kit-publication.md new file mode 100644 index 00000000..753b384d --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-media-kit-publication.md @@ -0,0 +1,195 @@ +# Media-kit publication implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. BASIC selected an implementer and independent reviewer for each round, in one PR. + +**Goal:** Publish contributions into the media kit without a declaration checklist, with separate authorized profile-picture actions through the website and MCP. + +**Architecture:** Reuse existing assets, gallery placements, uploads, review queues, receipts, and cleanup. Both approval paths publish additive gallery assets. Separate commands handle primary placement and bounded management of a contributor's own published assets. + +**Tech Stack:** Next.js, TypeScript, Convex, existing S3 upload/storage paths, Zod contracts, Node test runner, convex-test, Playwright, Storybook. No new dependencies. + +**Spec:** [Approved design](../specs/2026-09-30-media-kit-publication-design.md), committed as `60c7b0c` and approved by BASIC before this plan. + +## Global constraints + +- Deliver one PR with three internal phases: backend/contracts, website/MCP, migration/hosted verification. +- Use an implementer and an independent reviewer for each implementation round. +- Remove the checklist and Confirm evidence; do not auto-populate equivalent assertions. +- Direct trusted publication is own-contribution only, for public, published, unclaimed profiles. +- Keep contributor, publisher, reviewer, owner, and admin authority distinct; retain independent-review self-review refusal. +- Existing pictures do not block kit publication. Visible legacy/automatic artwork still occupies a picture slot. +- Preserve existing visibility, historical receipts, cleanup/legal holds, and one published charge per asset. +- Use durable actor-scoped idempotency keys; uncertain responses require same-key replay. +- Node `>=24 <25`, pnpm `10.15.1`; use repository scripts and generated Convex types. +- Claimed-profile proposals/notifications, logo bundles, and asset categorization are separate follow-ups. +- New substantive public prose requires BASIC's approval of its exact wording before shipping. +- No production migration or deployment during planning. Later production actions need their applicable authorization. +- Do not call a PR merge-ready until its latest pushed commit is at least 30 minutes old and all feedback/check surfaces have been refreshed for that exact head. + +## Review focus + +1. Existing managed, legacy, automatic, or private pictures remain untouched by additive publication (Task 1). +2. Another actor selecting the same asset invalidates a publisher's earlier undo authority (Task 2). +3. Lost responses, shared keys across command kinds, revoked grants, and newly claimed targets do not duplicate operations or expand authority (Tasks 1-4). +4. Metadata correction cannot erase the required credit/provenance, rewrite historical evidence, or expose private owner media (Task 3). +5. Old pending proposals and consumed uploads remain usable without duplicate blobs/charges; private or changed primary assets are never swept into a public migration (Task 6). + +## Preparation and file map + +Planning inspected `origin/main` at `a9f6263dd5d2c6c71fecad7d7f325e331c7baa31`. The planning checkout remains on an older implementation base. Start execution in a fresh isolated worktree from current main using the worktree skill; carry the approved spec and this plan as documentation commits. Re-read applicable AGENTS.md and inspect changes since this reference before editing. Do not rebase the shared planning checkout or extend the old merged PR. + +Modify existing seams: + +| Files | Responsibility | +| --- | --- | +| `convex/schema.ts`, `_trustedPublication.ts`, `_mediaReview.ts`, `profileMediaSubmissions.ts` | Proposal intent, additive approval, authority, snapshots, wrappers | +| `convex/_profileAssets.ts`, `profileAssets.ts` | Reusable placement/metadata mutations and owner-command provenance | +| `packages/api-contracts/src/{media-review,media-upload}.ts`, `src/index.ts` | Exact public command/detail contracts and exports | +| `convex/contributionUploads.ts`, `apps/web/src/lib/server/{mcp-media-upload,profile-media-mcp-import}.ts` | Local minted upload and URL-import placement normalization | +| `apps/web/src/lib/server/{mcp-media-review,vrdex-mcp}.ts` | MCP dispatch, delegation scopes, previews and receipts | +| Contribution editor, account contribution/review panels, public media-kit rendering | Website flow and shared action UI | +| `tests/backend`, `tests/web`, `packages/api-contracts/tests`, `apps/web/e2e` | Existing test runners and fixtures | +| MCP catalog/smoke scripts and `docs/engineering/media-review-commands.md` | Client catalog, hosted evidence, operating contract | + +Create only these focused implementation units: + +- `convex/_mediaContributionCommands.ts`: published-contribution snapshots and shared authorized placement/management commands. Existing browser and MCP wrappers call these functions. +- `tests/backend/media-contribution-commands.test.ts`: Task 2/3 authorization, concurrency, and receipt tests using `_mediaReviewFixture.ts`. +- `convex/mediaKitPublicationMigration.ts` and its backend test: bounded conversion and exact-resource correction, defaulting to dry-run. +- `apps/web/src/app/account/media-contributions/published-contribution-card.tsx`: actions for a published item, distinct from the pending publisher card. + +No new storage abstraction, review service, permission framework, or notification subsystem. + +## Phase 1: Backend and contracts + +### Task 1: Additive intake and publication without declarations + +**Files:** Modify the proposal/schema/contracts and upload/import seams above; tests in `tests/backend/trusted-publication.test.ts`, `profile-media-submissions.test.ts`, `media-review-authority.test.ts`, and `packages/api-contracts/tests/media-review.test.ts`. + +**Interfaces:** + +- Keep `MediaPublication = { submissionId, expectedReviewVersion, idempotencyKey }` and `publicationCommand(ctx, input: MediaPublication, actor: ReviewActor): Promise`. +- Add optional stored `requestKind: "kit_asset" | "identity_placement"` and `candidateAssetId: Id<"profileAssets">`; new image intake writes `kit_asset` and `requestedPlacement: "gallery"`. +- Add optional `profileAssets.sourceSubmissionId: Id<"profileMediaSubmissions">` as immutable contribution provenance. Set it when publishing an uploaded proposal. +- Owner upload placement behavior stays unchanged. Contributor legacy primary-placement inputs normalize to gallery instead of implicitly selecting identity placement. +- `reviewSnapshot(ctx, submission, profile)` keeps its existing return type but uses intent-specific relevant state. Kit snapshots exclude unrelated profile timestamps and existing identity artwork. Placement snapshots include that artwork and selection provenance. + +- [ ] **Write regression tests** using the existing upload fixture, without calling its declaration helper: + + ```ts + assert.equal(receipt.operationState, "committed"); + assert.deepEqual(newAssetPlacements.map(p => p.placement), ["gallery"]); + assert.equal(currentPrimaryAssetId, previousPrimaryAssetId); + assert.equal(evidenceRows.length, 0); + ``` + + Cover managed/private/legacy/automatic pictures, nonempty kit, independent approval, and no invented reviewer. A biography-only edit must preserve the inspected publication version; changed target identity, candidate metadata, privacy, claim, or restrictions must invalidate it. Retain capacity, verification, source/credit, suppression/rejection/dispute, and revoked-grant refusal tests. Same-key replay returns the original receipt and one asset/charge. +- [ ] **Run to confirm the new tests fail:** `node --conditions=import --import tsx --test tests/backend/trusted-publication.test.ts tests/backend/profile-media-submissions.test.ts tests/backend/media-review-authority.test.ts`. Expected: assertion failures against the old declaration/primary-slot behavior. +- [ ] **Implement the shared change:** remove assertion/empty-picture gates from kit eligibility, publish both approval paths through `consumeProfileAssetUploads` with gallery only, preserve credit/provenance, and use `sanitizeProfileAssetLabel(submission.label) ?? "Image"` for the required gallery title. Patch all intake callers, including local minted uploads and URL imports. A meaningful target change requires fresh inspection, never replacement of the stored bytes. Split kit freshness from primary freshness rather than disabling version checks globally. +- [ ] **Retire declarations:** keep old records untouched. Legacy declaration calls may replay their historical receipts; a fresh legacy declaration returns a terminal `declaration_retired` refusal without adding evidence. New publication does not call that handler. +- [ ] **Verify:** rerun the targeted tests, then `pnpm test:api-contracts`, `pnpm typecheck:api-contracts`, `pnpm typecheck:backend`. Regenerate Convex types through the normal local backend workflow if schema/API changes require it; do not edit generated files by hand. +- [ ] **Review and commit:** independent reviewer checks all intake/decision callers and the authority changes; fix findings, rerun affected checks, commit `feat: publish media contributions into the kit`. + +### Task 2: Explicit primary selection, undo, and reviewed replacement + +**Files:** Create `_mediaContributionCommands.ts` and `media-contribution-commands.test.ts`; modify schema, `_profileAssets.ts`, `profileAssets.ts`, `_mediaReview.ts`, `profileMediaSubmissions.ts`, and the media-review contract. + +**Interfaces:** + +- Export `PublishedContributionDetail`: string `submissionId`, `assetId`, `profileId`, `profileSlug`, `contributionVersion`; `metadata: { label: string; altText?: string; credit: string; creditUrl?: string; sourceUrl?: string; sourceDescription?: string }`; and `canSelectPrimary`, `canClearPrimary`, `canEditMetadata`, `canRemove` booleans. These are computed capabilities, not assertions supplied by a user. +- Export `ContributionCommandBase = { submissionId: string; expectedContributionVersion: string; idempotencyKey: string }`. +- Export `ContributionPlacementCommand = ContributionCommandBase & { action: "select_primary" | "clear_primary" }` with strict schemas and existing bounded ID/version/key limits. +- Implement `publishedContributionDetail(ctx: QueryCtx | MutationCtx, submissionId: string, actor: ReviewActor): Promise` and `contributionPlacementCommand(ctx: MutationCtx, input: ContributionPlacementCommand, actor: ReviewActor): Promise` in the new module. +- Browser/MCP wrappers: `contributionDetail`, `contributionDetailForMcpActor`, `placeContribution`, `placeContributionForMcpActor` in `profileMediaSubmissions.ts`. Use the existing verified actor and MCP attestation patterns. +- `proposeContributionPlacement(ctx: MutationCtx, input: ContributionCommandBase, actor: ReviewActor): Promise` creates an `identity_placement` proposal referencing the existing asset; expose `proposePlacement` and `proposePlacementForMcpActor`. Its receipt resource ID is the new review submission. +- Add optional placement `selectionActorUserId` and `selectionOperationId`. Every owner/admin/reviewer/publisher identity-selection path writes fresh provenance, including same-asset selection. Legacy missing provenance never grants contributor undo. + +- [ ] **Write tests** for a publisher's own public kit asset in an unclaimed profile: + + ```ts + assert.equal(selected.operationState, "committed"); + assert.deepEqual(activePlacements.sort(), ["gallery", "profile_image"]); + assert.equal(cleared.operationState, "committed"); + assert.deepEqual(afterClearPlacements, ["gallery"]); + assert.equal(asset.state, "active"); + ``` + + Cover community primary logo, existing visible fallbacks, another contributor's asset, revoked publisher, newly claimed/private target, and stale version. Another authorized actor selecting the same asset must invalidate undo. Reviewed replacement must keep gallery membership of both assets, require a distinct reviewer, and create no new upload/blob/published charge. Repeated placement commands replay one receipt; reuse of the key with another command refuses. +- [ ] **Run and observe failure:** `node --conditions=import --import tsx --test tests/backend/media-contribution-commands.test.ts`. +- [ ] **Implement:** resolve immutable source submission and active public asset; check current verified actor, authority and restriction state in the transaction. Selection fills an actually empty identity slot only. Clearing requires the exact unchanged self-selection and current publisher authority. Retain gallery when modifying the primary slot. Owner/admin selection uses its existing authority, but stamps new provenance. +- [ ] **Extend review:** an `identity_placement` request uses the existing queue/decision receipts, stored public asset preview, source/credit and narrow placement snapshot. Apply existing bounded proposal counts/rate limits without reserving new uploaded bytes. Approval changes placement only. Rejection/withdrawal records the placement-request disposition, without retiring the kit asset, creating a digest-wide content rejection, or scheduling its bytes for proposal-blob cleanup. Explicit moderator suppression remains a separate existing action. +- [ ] **Verify:** targeted command tests, `media-review-authority.test.ts`, `profile-assets-management.test.ts`, API-contract tests and backend typecheck. Extend preview tests to prove an existing-asset proposal reads the authorized stored object, not its source URL. +- [ ] **Review and commit:** reviewer checks same-asset reselection, all owner/legacy selection callers, and retention/capacity interactions; commit `feat: separate profile picture selection from publication`. + +### Task 3: Own published-item corrections and removal + +**Files:** Extend `_mediaContributionCommands.ts`, schema and wrappers; tests in `media-contribution-commands.test.ts` and the media-review contract tests. + +**Interfaces:** + +- Export `ContributionMetadataPatch` with optional `label`, `altText`, `credit`, `creditUrl`, `sourceUrl`, `sourceDescription` fields, using existing sanitizers/limits. Clearing optional values uses `null`; required credit and at least one provenance field cannot be cleared. +- Export strict `ContributionManageCommand`: command base plus either `{ action: "update_metadata", metadata: ContributionMetadataPatch }` or `{ action: "remove" }`. +- Implement `contributionManageCommand(ctx: MutationCtx, input: ContributionManageCommand, actor: ReviewActor): Promise`; expose browser `manageContribution` and attested `manageContributionForMcpActor` wrappers. +- Add optional asset `sourceDescription` for corrected local provenance. Preserve the original submission/evidence; the published-detail projection reads current asset metadata. + +- [ ] **Write tests:** own kit-only corrections commit and change `contributionVersion`; empty credit/provenance and wrong-submitter edits refuse. Removal excludes the item from the public kit through existing logical deletion/retention. Active primary placement, another actor's selection, newly claimed target, suppression, and private owner media block contributor management. Own approved history remains readable without leaking current private asset bytes/metadata. Ordinary contributors can manage their own independently approved kit-only items without a publisher grant. +- [ ] **Confirm failure** with the Task 2 command-test invocation. +- [ ] **Implement bounded management:** allow only the listed metadata fields, never bytes, target, visibility, or arbitrary placement changes. Recheck profile/asset/provenance and verification; retain audit before/after metadata and command receipt. Use existing retirement/cleanup machinery and legal-hold behavior. Update capability/version projections after every mutation. +- [ ] **Verify:** command tests, existing asset-management tests, contract tests and backend typecheck; prove same-key retry cannot produce repeated correction/removal or mutate a different item. +- [ ] **Review and commit:** reviewer checks post-review metadata permissions, private projections and cleanup references; commit `feat: manage own published media contributions`. + +## Phase 2: Website and MCP parity + +### Task 4: MCP contracts, dispatch and catalog + +**Files:** Modify `apps/web/src/lib/server/mcp-media-review.ts`, `vrdex-mcp.ts`, API-contract exports, `tests/web/mcp-media-review.test.ts`, `vrdex-mcp.test.ts`, `scripts/smoke-vrdex-mcp-compat.ts`, `scripts/smoke-mcp-inspector-client.ts`, and their `tests/scripts` smoke tests. + +**Interfaces:** + +- Keep `vrdex_media_submission_publish` with its existing input shape, now kit-only. +- Add `vrdex_media_contribution_get` (submission ID, `mcp:read` + `assets:contribute`), `vrdex_media_contribution_manage` (Task 3 input, `mcp:write` + `assets:contribute`), `vrdex_media_contribution_place` (Task 2 placement input, `mcp:write` + `assets:publish`), and `vrdex_media_contribution_propose_placement` (command base, `mcp:write` + `assets:contribute`). +- Use the backend functions from Tasks 2/3. Existing owner `vrdex_profile_media_manage` remains owner-scoped; do not widen `assets:write` or route contributor management through owner wrappers. +- Declaration is absent from advertised tools; compatibility dispatch retains terminal historical behavior from Task 1. + +- [ ] **Write transport tests:** no declare call is needed before publish; each new tool dispatches the exact input to the correct attested wrapper and validates `CommandReceipt`/detail output. Missing operation scope prevents backend execution. `assets:contribute` alone cannot select/clear primary even when the account has a publisher grant. Contributor projections omit private reviewer information and private owner assets. +- [ ] **Run to failure:** `node --import tsx --test tests/web/mcp-media-review.test.ts tests/web/vrdex-mcp.test.ts`. +- [ ] **Implement registration/mapping:** extend the existing handlers and write-audit machinery. Add scope hints consistent with the existing OAuth recovery path. Provide useful action descriptions that distinguish kit publication, primary placement, and reviewed replacement; treat substantive descriptions as reviewable copy before shipping. +- [ ] **Update independent catalog expectations:** add new tools/schemas and remove declaration from advertisement in the web catalog fixture and compatibility/Inspector smoke scripts. Preserve independent expected assertions rather than generating them from the implementation under test. +- [ ] **Verify:** targeted web tests, `pnpm verify:api-contracts`, `pnpm verify:vrdex-mcp`, `pnpm typecheck:web`; local compatibility smoke shows the intended catalog but is not hosted mutation proof. +- [ ] **Review and commit:** reviewer inspects scopes, actor binding, retries, privacy and compatibility behavior; commit `feat: expose media kit contribution actions through MCP`. + +### Task 5: Website publication and published-item actions + +**Files:** Modify `apps/web/src/app/_components/profile-media-contribution-editor.tsx`, account `media-contributions/{publication-card,media-contributions-panel}.tsx`, account `media-review/media-review-panel.tsx` and `media-review/media-review-view.ts`, and `apps/web/src/app/_components/profile-public-page.tsx` for verified gallery title/download behavior. Create `published-contribution-card.tsx`. Update `apps/web/src/components/trusted-publication.stories.tsx`, `media-review-comparison.stories.tsx`, `apps/web/e2e/media-contribution.flow.spec.ts`, `media-kit.visual.spec.ts`, and `tests/web/{media-command-result,media-review-view,profile-media-kit}.test.ts`. + +**Interfaces:** Pending card consumes `ReviewDetail` and calls Publish once. Published card consumes `PublishedContributionDetail`, calls the Task 2/3 wrappers and exposes only current capabilities. Reviewed replacement sends the Task 2 proposal command. Use existing result/refusal/retry components and retain the exact pending payload/key after an uncertain response. + +- [ ] **Write flow assertions:** the trusted publisher card has zero declaration checkboxes and no Confirm evidence action. Publish makes the kit item visible without changing the hero/avatar. Published item allows eligible picture selection, undo, metadata editing and removal. A protected selection has no contributor removal action. Ordinary review approval publishes into the kit; explicit replacement compares current picture with the published candidate. +- [ ] **Confirm failure** using the existing disposable authenticated media fixture environment. In PowerShell set `$env:VRDEX_E2E_MEDIA_LIFECYCLE='true'`, then run `pnpm --filter web exec playwright test e2e/media-contribution.flow.spec.ts`. Extend its fixture grants/OAuth scopes for publisher and distinct reviewer scenarios. Require executed assertions rather than a skipped suite; absent fixture credentials are a blocker, not proof. Storybook fixtures provide deterministic pending/published/refused/unknown-outcome cases. +- [ ] **Implement the journey:** submit gallery intent; simplify pending card to preview, destination, attribution, readable source and one primary Publish action. Keep independent review secondary. Render published actions separately and derive visibility from backend capabilities. Show source description as text when no URL exists; URL links use validated existing URLs and readable labels rather than raw signed attachment strings. Preserve authentication return paths and direct contribution links. +- [ ] **Implement correction UI:** edit current asset metadata, distinguish clearing primary from removing the kit item, and send protected replacements to review without another upload. Retain candidate bytes and draft metadata on conflicts/errors; refresh capabilities before a new decision. +- [ ] **Verify:** `pnpm typecheck:web`, targeted media result/view/kit tests, local contribution flow, desktop/mobile Storybook and public-kit screenshots. Use a VLM to review spacing, button hierarchy, long sources, disabled/protected states, keyboard accessibility, and no implied avatar placement. Show any substantive new public wording verbatim to BASIC before shipping. +- [ ] **Review and commit:** reviewer checks UI/backend parity and pending-key recovery; commit `feat: simplify media kit publication and picture actions`. + +## Phase 3: Conversion and hosted verification + +### Task 6: Bounded migration, Karly correction and end-to-end proof + +**Files:** Create `convex/mediaKitPublicationMigration.ts`, `tests/backend/media-kit-publication-migration.test.ts`; modify `tests/backend/hosted-mcp-profile-media-contributions.test.ts`, `apps/web/e2e/media-contribution.flow.spec.ts` for actual hosted lifecycle proof, `docs/engineering/media-review-commands.md`, and `docs/testing/mcp-media-staging-lifecycle.md`. + +**Interfaces:** Export an operator-only internal mutation `convertBatch({ dryRun: boolean, cursor: string | null, limit: number })` returning `{ scanned, changed, skipped, conflicts, continueCursor, isDone }`, with limit `1..40`. Dry-run is the default in its operator wrapper. It converts pending uploaded proposals to `kit_asset`/gallery and increments review revision without changing uploaded bytes or historical receipts. It also backfills immutable source-submission provenance only from unique matching approved records. + +For the explicit correction, export internal `correctPrimaryToKit({ dryRun: boolean, submissionId, expectedAssetId, expectedPlacementId, expectedPlacementUpdatedAt })`. Require exact current public asset/profile linkage and unchanged eligible primary placement; return a report/refusal, never select another image. Karly's current approved submission is `x1747ftcbeq1k475kz4hqdn77d8fcz0r`; resolve current IDs in dry-run, do not hardcode guessed asset IDs. Keep production correction separate from generic conversion. + +- [ ] **Write tests:** repeated conversion changes nothing twice; stored candidate/digest and source stay intact; old review versions refuse; a refreshed pending proposal publishes without re-upload. Already consumed/public assets keep one published charge. Nonunique legacy provenance is skipped, not guessed. Private, claimed, changed or protected primary records refuse the exact correction, while the permitted correction leaves an active public gallery asset and no authored primary placement. +- [ ] **Run to failure:** `node --conditions=import --import tsx --test tests/backend/media-kit-publication-migration.test.ts`. +- [ ] **Implement bounded conversion/reporting:** preserve deliberately selected and ambiguous existing pictures, historical assertion/reviewer identities and privacy. Add `profileMediaSubmissions.by_approvedAssetId` for a bounded two-record uniqueness check before backfilling an asset's source submission. Add gallery only to already public contribution assets requiring retention protection. Do not publish private owner media or invent legacy selection provenance. Reconcile legacy uploaded intents at finalization so an upload admitted before conversion can finish into the new kit flow. +- [ ] **Run regression gates:** targeted media suites, contracts, MCP and web verification, markdown checks, and `pnpm check:backend:generated`. Update operating docs to distinguish kit approval from placement approval, retirement from byte deletion, and old-client behavior. +- [ ] **Prove staging through actual authenticated MCP:** upload to the minted endpoint, complete, inspect, publish without declare, get approved state, view/download kit, select/undo primary, correct/remove kit-only item, submit a replacement and have a distinct authorized reviewer decide it. Include existing-picture publication, an unrelated profile edit, a stale selection, a protected same-asset reselection, revoked authority and same-key lost-response recovery. Use existing disposable staging identities/resources and runtime; keep raw tokens/upload targets out of public evidence. +- [ ] **Prove website:** exercise the same staging publisher/reviewer capabilities through browser UI; capture public kit and unchanged/selected/restored picture screenshots on desktop/mobile. Check the deployed feature gate and public field visibility. A receipt alone is insufficient if the asset does not render or download. +- [ ] **Independent review and one PR:** fix findings, commit the migration/proof/docs round, run whole-branch review and required checks. Publish the PR when authorized. Each change round uses a fresh implementer and independent reviewer; handle/reply to/resolve valid review threads before pushing. After the latest commit's 30-minute window, refresh exact-head checks, PR comments, threads, reviews, mutable AI summaries and mergeability. Do not merge automatically. +- [ ] **Deployment handoff:** provide the bounded migration dry-run report, Karly exact-resource correction preview, backend/web ordering and hosted evidence. With production authorization, apply reviewed conversion/correction and verify production kit/download/placement state. Planning or implementation approval alone is not authorization for those live actions. + +## Completion and handoff + +All acceptance items in the approved spec map to Tasks 1-6. Claimed-profile owner-notification proposals remain documented follow-up work, without scaffolding in this PR. Stop after this plan is reviewed; then execute with the already selected subagent implement/review method. From 4b502d12e91d0cab04da6eca151ebb8ad567718e Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Thu, 1 Oct 2026 03:20:59 -0400 Subject: [PATCH 03/28] docs: bind correction to selection provenance --- docs/superpowers/plans/2026-10-01-media-kit-publication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-01-media-kit-publication.md b/docs/superpowers/plans/2026-10-01-media-kit-publication.md index 753b384d..9128f3b5 100644 --- a/docs/superpowers/plans/2026-10-01-media-kit-publication.md +++ b/docs/superpowers/plans/2026-10-01-media-kit-publication.md @@ -179,7 +179,7 @@ No new storage abstraction, review service, permission framework, or notificatio **Interfaces:** Export an operator-only internal mutation `convertBatch({ dryRun: boolean, cursor: string | null, limit: number })` returning `{ scanned, changed, skipped, conflicts, continueCursor, isDone }`, with limit `1..40`. Dry-run is the default in its operator wrapper. It converts pending uploaded proposals to `kit_asset`/gallery and increments review revision without changing uploaded bytes or historical receipts. It also backfills immutable source-submission provenance only from unique matching approved records. -For the explicit correction, export internal `correctPrimaryToKit({ dryRun: boolean, submissionId, expectedAssetId, expectedPlacementId, expectedPlacementUpdatedAt })`. Require exact current public asset/profile linkage and unchanged eligible primary placement; return a report/refusal, never select another image. Karly's current approved submission is `x1747ftcbeq1k475kz4hqdn77d8fcz0r`; resolve current IDs in dry-run, do not hardcode guessed asset IDs. Keep production correction separate from generic conversion. +For the explicit correction, export internal `correctPrimaryToKit({ dryRun: boolean, submissionId, expectedAssetId, expectedPlacementId, expectedPlacementUpdatedAt, expectedSelectionOperationId: string | null })`. Require exact current public asset/profile linkage and unchanged eligible primary placement, including its selection provenance; return a report/refusal, never select another image. Karly's current approved submission is `x1747ftcbeq1k475kz4hqdn77d8fcz0r`; resolve current IDs in dry-run, do not hardcode guessed asset IDs. Keep production correction separate from generic conversion. - [ ] **Write tests:** repeated conversion changes nothing twice; stored candidate/digest and source stay intact; old review versions refuse; a refreshed pending proposal publishes without re-upload. Already consumed/public assets keep one published charge. Nonunique legacy provenance is skipped, not guessed. Private, claimed, changed or protected primary records refuse the exact correction, while the permitted correction leaves an active public gallery asset and no authored primary placement. - [ ] **Run to failure:** `node --conditions=import --import tsx --test tests/backend/media-kit-publication-migration.test.ts`. From b1bc1f5bec31bd701c42e3a7df799ae1e2b4fab4 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Thu, 1 Oct 2026 03:29:13 -0400 Subject: [PATCH 04/28] chore: ignore local development worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2ffda6ca..5b2b593b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ __pycache__/ # Claude Code personal settings .claude/settings.local.json + +# Local isolated development checkouts +.worktrees/ From 8345c94931d467982e9f5f3ef6190aafc818c232 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Fri, 2 Oct 2026 03:23:38 -0400 Subject: [PATCH 05/28] feat: publish media contributions into the kit --- convex/_mediaReview.ts | 110 ++++-------- convex/_profileAssets.ts | 1 + convex/_trustedPublication.ts | 106 ++---------- convex/contributionUploads.ts | 25 +-- convex/profileMediaSubmissions.ts | 58 +++---- convex/schema.ts | 3 + docs/engineering/media-review-commands.md | 74 ++++---- packages/api-contracts/src/media-review.ts | 2 + .../api-contracts/tests/media-review.test.ts | 11 ++ ...ed-mcp-profile-media-contributions.test.ts | 2 +- tests/backend/media-review-assignment.test.ts | 3 +- tests/backend/media-review-authority.test.ts | 8 +- .../backend/profile-media-submissions.test.ts | 43 ++--- tests/backend/trusted-publication.test.ts | 160 ++++++++++-------- 14 files changed, 252 insertions(+), 354 deletions(-) diff --git a/convex/_mediaReview.ts b/convex/_mediaReview.ts index 9b2b60c6..871e3c4f 100644 --- a/convex/_mediaReview.ts +++ b/convex/_mediaReview.ts @@ -250,7 +250,7 @@ export async function applyReviewDecision( } if ( profile.updatedAt !== args.expectedProfileUpdatedAt || - (args.decision === "approve" && + (submission.requestKind === "identity_placement" && args.decision === "approve" && profile.updatedAt !== submission.targetProfileUpdatedAt) ) { throw new ConvexError({ @@ -310,14 +310,14 @@ export async function applyReviewDecision( ) .first(); if ( - (currentPlacement?.assetId ?? undefined) !== - submission.targetPlacementAssetId + submission.requestKind === "identity_placement" && + (currentPlacement?.assetId ?? undefined) !== submission.targetPlacementAssetId ) { throw new Error( "The profile media placement changed. Refresh before deciding.", ); } - if (await privateReplacementRequiresElevatedAccess( + if (submission.requestKind === "identity_placement" && await privateReplacementRequiresElevatedAccess( ctx, profile._id, currentPlacement?.assetId, ownsProfile || access.superAdmin, )) throw new Error("Private replacement requires profile owner or admin access."); @@ -335,17 +335,7 @@ export async function applyReviewDecision( throw new Error("This image is already published on the profile."); } - const finalPlacement = args.finalPlacement ?? submission.requestedPlacement; - if ( - (profile.profileType === "person" && finalPlacement !== "profile_image") || - (profile.profileType === "community" && finalPlacement !== "primary_logo") - ) { - throw new Error( - "That media placement is not available for this profile type.", - ); - } - const finalLabel = - "label" in args ? sanitizeProfileAssetLabel(args.label) : submission.label; + const finalLabel = sanitizeProfileAssetLabel("label" in args ? args.label : submission.label) ?? "Image"; const finalAltText = "altText" in args ? sanitizeProfileAssetAltText(args.altText) @@ -377,7 +367,7 @@ export async function applyReviewDecision( altText: finalAltText, credit: finalCredit, creditUrl: finalCreditUrl, - placements: [finalPlacement], + placements: ["gallery"], }, ], source: "community_submitted", @@ -514,6 +504,13 @@ export async function reviewSnapshot( .unique(), ), ); + const kitAsset = submission.requestKind !== "identity_placement"; + const restrictions = await Promise.all([ + ctx.db.query("mediaPublicationRestrictions").withIndex("by_profileId_kind", q => q.eq("profileId", profile._id).eq("kind", "identity")).collect(), + ctx.db.query("mediaPublicationRestrictions").withIndex("by_profileId_kind", q => q.eq("profileId", profile._id).eq("kind", "dispute")).collect(), + submission.contentSha256 ? ctx.db.query("mediaPublicationRestrictions").withIndex("by_contentSha256_kind", q => q.eq("contentSha256", submission.contentSha256).eq("kind", "rejection")).collect() : [], + submission.contentSha256 ? ctx.db.query("mediaPublicationRestrictions").withIndex("by_contentSha256_kind", q => q.eq("contentSha256", submission.contentSha256).eq("kind", "suppression")).collect() : [], + ]); const reviewVersion = await hash({ candidate: { id: submission._id, @@ -531,23 +528,31 @@ export async function reviewSnapshot( label: submission.label, altText: submission.altText, expiresAt: submission.expiresAt, - targetProfileUpdatedAt: submission.targetProfileUpdatedAt, - targetPlacementAssetId: submission.targetPlacementAssetId, + targetProfileUpdatedAt: kitAsset ? undefined : submission.targetProfileUpdatedAt, + targetPlacementAssetId: kitAsset ? undefined : submission.targetPlacementAssetId, requestedPlacement: submission.requestedPlacement, + requestKind: submission.requestKind, + candidateAssetId: submission.candidateAssetId, }, intent, - profile, - placement, - currentAsset, - currentAutomaticImageUrl, - authoredPlacements, - artworkEvidence: artworkEvidence.map((row) => row && ({ + restrictions, + profile: kitAsset ? { + id: profile._id, profileType: profile.profileType, slug: profile.slug, + displayName: profile.displayName, claimState: profile.claimState, + publicationState: profile.publicationState, publicSurfacingState: profile.publicSurfacingState, + fieldVisibility: profile.fieldVisibility, + } : profile, + placement: kitAsset ? undefined : placement, + currentAsset: kitAsset ? undefined : currentAsset, + currentAutomaticImageUrl: kitAsset ? undefined : currentAutomaticImageUrl, + authoredPlacements: kitAsset ? undefined : authoredPlacements, + artworkEvidence: kitAsset ? undefined : artworkEvidence.map((row) => row && ({ key: row.key, kind: row.kind, locator: row.locator, provider: row.provider, status: row.status, entityId: row.entityId, artworkSourceUrl: row.artworkSourceUrl, artworkType: row.artworkType, observedAt: row.observedAt, })), - currentImage, - effectiveAsset, + currentImage: kitAsset ? undefined : currentImage, + effectiveAsset: kitAsset ? undefined : effectiveAsset, revision: submission.reviewRevision ?? 0, }); return { @@ -637,11 +642,13 @@ export async function decideReviewCommand( code = "review_changed"; else if ( args.decision === "approve" && + submission.requestKind === "identity_placement" && profile.updatedAt !== submission.targetProfileUpdatedAt ) code = "target_changed"; else if ( args.decision === "approve" && + submission.requestKind === "identity_placement" && (snapshot.currentPlacement?.assetId ?? undefined) !== submission.targetPlacementAssetId ) @@ -651,19 +658,13 @@ export async function decideReviewCommand( else if (args.decision === "approve" && snapshot.candidate.rendition === null) code = "candidate_unavailable"; if (code === undefined && args.decision === "approve" && + submission.requestKind === "identity_placement" && await privateReplacementRequiresElevatedAccess( ctx, profile._id, snapshot.currentPlacement?.assetId, authority.ownsProfile || authority.access.superAdmin, )) code = "private_replacement_requires_owner_or_admin"; if (code === undefined && args.decision === "approve") { - if ( - (profile.profileType === "person" && - submission.requestedPlacement !== "profile_image") || - (profile.profileType === "community" && - submission.requestedPlacement !== "primary_logo") - ) - code = "placement_unavailable"; - else if (!submission.credit.trim()) code = "credit_required"; + if (!submission.credit.trim()) code = "credit_required"; else if (submission.contentSha256 !== undefined) { const duplicate = await ctx.db .query("profileAssets") @@ -678,46 +679,7 @@ export async function decideReviewCommand( } } if (code === undefined && args.decision === "approve") { - let retiringPublicAsset = 0; - if (snapshot.currentPlacement !== null) { - const asset = await ctx.db.get(snapshot.currentPlacement.assetId); - if ( - asset !== null && - asset.profileId === profile._id && - asset.state === "active" && - asset.visibility === "public" && - asset.retiredAt === undefined - ) { - const [before, after] = await Promise.all([ - ctx.db - .query("profileAssetPlacements") - .withIndex("by_assetId_state_placement", (q) => - q - .eq("assetId", asset._id) - .eq("state", "active") - .lt("placement", submission.requestedPlacement), - ) - .first(), - ctx.db - .query("profileAssetPlacements") - .withIndex("by_assetId_state_placement", (q) => - q - .eq("assetId", asset._id) - .eq("state", "active") - .gt("placement", submission.requestedPlacement), - ) - .first(), - ]); - if (before === null && after === null) retiringPublicAsset = 1; - } - } - if ( - !(await hasProfileAssetCapacity( - ctx.db, - profile._id, - 1 - retiringPublicAsset, - )) - ) + if (!(await hasProfileAssetCapacity(ctx.db, profile._id, 1))) code = "capacity_exceeded"; } const receipt: CommandReceipt = { diff --git a/convex/_profileAssets.ts b/convex/_profileAssets.ts index 6b059aff..23a201f9 100644 --- a/convex/_profileAssets.ts +++ b/convex/_profileAssets.ts @@ -796,6 +796,7 @@ export async function consumeProfileAssetUploads( const creditUrl = sanitizeProfileAssetCreditUrl(upload.creditUrl); validateProfileAssetGalleryPlacements(upload.placements, label); const assetId = await db.insert("profileAssets", { + ...(input.approvedSubmissionId === undefined ? {} : { sourceSubmissionId: input.approvedSubmissionId }), profileId: input.profileId, storageKey: intent.storageKey, ...(intent.sourceStorageKey !== undefined && intent.sourceContentSha256 !== undefined diff --git a/convex/_trustedPublication.ts b/convex/_trustedPublication.ts index e380f20c..c34546ce 100644 --- a/convex/_trustedPublication.ts +++ b/convex/_trustedPublication.ts @@ -11,6 +11,7 @@ import { import { consumeProfileAssetUploads, hasProfileAssetCapacity, + sanitizeProfileAssetLabel, } from "./_profileAssets"; import { mediaPublicationSchema, @@ -27,13 +28,6 @@ export type PublicationFacts = { ownSubmission: boolean; sourceRecorded: boolean; credit: string; - identityConfirmed: boolean; - attributionConfirmed: boolean; - publicationPermitted: boolean; - noKnownRestrictions: boolean; - currentPlacement: unknown | null; - legacyImageUrl: string | null; - automaticImageUrl: string | null; priorSuppression: boolean; priorRejection: boolean; unresolvedDispute: boolean; @@ -45,13 +39,6 @@ export function eligible(f: PublicationFacts): boolean { f.ownSubmission && f.sourceRecorded && !!f.credit.trim() && - f.identityConfirmed && - f.attributionConfirmed && - f.publicationPermitted && - f.noKnownRestrictions && - f.currentPlacement === null && - !f.legacyImageUrl && - !f.automaticImageUrl && !f.priorSuppression && !f.priorRejection && !f.unresolvedDispute @@ -108,28 +95,15 @@ async function digest(value: unknown) { (b) => b.toString(16).padStart(2, "0"), ).join(""); } -// Candidate-only revision keeps declarations stable across target refreshes, but never across evidence edits. -export function evidenceRevision(submission: Doc<"profileMediaSubmissions">) { - return digest({ - submissionId: submission._id, - submitterUserId: submission.submitterUserId, - originalFileName: submission.originalFileName, - contributorNote: submission.contributorNote, - label: submission.label, - altText: submission.altText, - hash: submission.contentSha256, - intent: submission.uploadIntentId, - sourceUrl: submission.sourceUrl, - sourceKind: submission.sourceKind, - sourceDescription: submission.sourceDescription, - credit: submission.credit, - creditUrl: submission.creditUrl, - placement: submission.requestedPlacement, - profileId: submission.profileId, - }); +export function publicationCommand(ctx: MutationCtx, input: MediaPublication, actor: ReviewActor): Promise { + return publicationOrLegacyDeclaration(ctx, input, actor); } -export async function publicationCommand( +export function legacyDeclarationCommand(ctx: MutationCtx, input: PublicationEvidence, actor: ReviewActor): Promise { + return publicationOrLegacyDeclaration(ctx, input, actor, true); +} + +async function publicationOrLegacyDeclaration( ctx: MutationCtx, input: MediaPublication | PublicationEvidence, actor: ReviewActor, @@ -168,7 +142,7 @@ export async function publicationCommand( code: "idempotency_conflict", }; const snapshot = await reviewSnapshot(ctx, submission, profile); - const revision = await evidenceRevision(submission); + let code = process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED !== "true" ? "review_disabled" @@ -181,24 +155,8 @@ export async function publicationCommand( : !snapshot.candidate.rendition || !submission.contentSha256 ? "candidate_unavailable" : undefined; + if (declaration) code = "declaration_retired"; if (!declaration && !code) { - const otherImage = await ctx.db - .query("profileAssetPlacements") - .withIndex("by_profileId_placement_state_position", (q) => - q - .eq("profileId", profile._id) - .eq( - "placement", - submission.requestedPlacement === "profile_image" - ? "primary_logo" - : "profile_image", - ) - .eq("state", "active"), - ) - .first(); - const evidence = submission.publicationEvidenceId - ? await ctx.db.get(submission.publicationEvidenceId) - : null; const [ identityRestriction, disputeRestriction, @@ -262,10 +220,6 @@ export async function publicationCommand( await ctx.db.patch(submission._id, { priorRestrictionId: restriction._id, }); - const confirmed = - evidence?.actorUserId === actor.user._id && - evidence.submissionId === submission._id && - evidence.candidateVersion === revision; if ( !eligible({ publisherGrant: access.canPublishMedia, @@ -277,30 +231,12 @@ export async function publicationCommand( (submission.sourceKind === "local" && !!submission.sourceDescription?.trim()), credit: submission.credit, - identityConfirmed: confirmed && evidence.identityConfirmed, - attributionConfirmed: confirmed && evidence.attributionConfirmed, - publicationPermitted: confirmed && evidence.publicationPermitted, - noKnownRestrictions: confirmed && evidence.noKnownRestrictions, - currentPlacement: snapshot.currentPlacement ?? otherImage, - legacyImageUrl: snapshot.currentAvatarImageUrl, - automaticImageUrl: snapshot.currentAutomaticImageUrl, priorSuppression: !!suppressedAsset, priorRejection: !!priorRejected, unresolvedDispute: !!restriction, }) ) code = "independent_review_required"; - else if (profile.updatedAt !== submission.targetProfileUpdatedAt) - code = "target_changed"; - else if (submission.targetPlacementAssetId !== undefined) - code = "independent_review_required"; - else if ( - (profile.profileType === "person" && - submission.requestedPlacement !== "profile_image") || - (profile.profileType === "community" && - submission.requestedPlacement !== "primary_logo") - ) - code = "placement_unavailable"; else if (!(await hasProfileAssetCapacity(ctx.db, profile._id, 1))) code = "capacity_exceeded"; } @@ -310,23 +246,7 @@ export async function publicationCommand( operationState: code ? "refused" : "committed", ...(code ? { code } : {}), }; - if (!code && declaration) { - const values = publicationEvidenceSchema.parse(args); - const evidenceId = await ctx.db.insert("mediaPublicationEvidence", { - submissionId: submission._id, - actorUserId: actor.user._id, - candidateVersion: revision, - identityConfirmed: values.identityConfirmed, - attributionConfirmed: values.attributionConfirmed, - publicationPermitted: values.publicationPermitted, - noKnownRestrictions: values.noKnownRestrictions, - createdAt: Date.now(), - }); - await ctx.db.patch(submission._id, { - publicationEvidenceId: evidenceId, - reviewRevision: (submission.reviewRevision ?? 0) + 1, - }); - } else if (!code) { + if (!code) { const intent = submission.uploadIntentId ? await ctx.db.get(submission.uploadIntentId) : null; @@ -340,11 +260,11 @@ export async function publicationCommand( { intentId: intent._id, uploadToken: intent.uploadToken, - label: submission.label, + label: sanitizeProfileAssetLabel(submission.label) ?? "Image", altText: submission.altText, credit: submission.credit, creditUrl: submission.creditUrl, - placements: [submission.requestedPlacement], + placements: ["gallery"], }, ], source: "community_submitted", diff --git a/convex/contributionUploads.ts b/convex/contributionUploads.ts index 5528901d..5f044415 100644 --- a/convex/contributionUploads.ts +++ b/convex/contributionUploads.ts @@ -109,18 +109,18 @@ async function target( actor: Id<"users">, profileId: Id<"profiles">, mode: "owner" | "contributor", - placement: "profile_image" | "primary_logo", + placement: "profile_image" | "primary_logo" | "gallery", expected: number, ) { const profile = await ctx.db.get(profileId); if (!profile || profile.updatedAt !== expected) throw new ConvexError({ code: "UPLOAD_TARGET_CHANGED" }); + if (mode === "contributor") return assertEligibleTarget(profile, "gallery"); if ( (profile.profileType === "person" ? "profile_image" : "primary_logo") !== placement ) throw new ConvexError({ code: "UPLOAD_PLACEMENT_INVALID" }); - if (mode === "contributor") return assertEligibleTarget(profile, placement); if ( profile.claimState === "unclaimed" || !(await userOwnsProfile(ctx.db, profileId, actor)) @@ -443,15 +443,6 @@ export const begin = internalMutation({ tokenIdentifier: `api:${actorUserId}`, }; const expiresAt = now + 10 * 60 * 1000; - const placement = await ctx.db - .query("profileAssetPlacements") - .withIndex("by_profileId_placement_state_position", (q) => - q - .eq("profileId", profile._id) - .eq("placement", request.placement) - .eq("state", "active"), - ) - .first(); const submissionId = request.mode === "owner" ? undefined @@ -461,14 +452,15 @@ export const begin = internalMutation({ targetProfileDisplayName: profile.displayName, submitterUserId: actorUserId, submitter: subject, - requestedPlacement: request.placement, + requestKind: "kit_asset", + requestedPlacement: "gallery", sourceUrl, sourceKind: "local", sourceDescription: request.sourceDescription, credit: request.credit, status: "upload_pending", targetProfileUpdatedAt: profile.updatedAt, - targetPlacementAssetId: placement?.assetId, + expiresAt, createdAt: now, updatedAt: now, @@ -481,8 +473,9 @@ export const begin = internalMutation({ mimeType: request.contentType, byteSize: request.byteLength, credit: request.credit, + ...(request.mode === "owner" ? {} : { label: "Image" }), sourceUrl, - placements: [request.placement], + placements: [request.mode === "owner" ? request.placement : "gallery"], source: request.mode === "owner" ? "owner_authored" : "community_submitted", purpose: @@ -583,7 +576,7 @@ export const claim = internalMutation({ args.actorUserId, row.profileId, row.mode, - intent.placements![0] as "profile_image" | "primary_logo", + intent.placements![0] as "profile_image" | "primary_logo" | "gallery", row.expectedUpdatedAt, ); if (row.expiresAt <= Date.now()) @@ -770,7 +763,7 @@ export const complete = internalMutation({ args.actorUserId, row.profileId, row.mode, - intent.placements![0] as "profile_image" | "primary_logo", + intent.placements![0] as "profile_image" | "primary_logo" | "gallery", row.expectedUpdatedAt, ); if ( diff --git a/convex/profileMediaSubmissions.ts b/convex/profileMediaSubmissions.ts index c248f0ab..8d9496ab 100644 --- a/convex/profileMediaSubmissions.ts +++ b/convex/profileMediaSubmissions.ts @@ -1,6 +1,6 @@ import { assertLiveContributionToken } from "./_contributionAuth"; import { effectiveContributionPolicy, contributionChargeRefusal, registerLegacyContribution, reservationBytes } from "./_contributionCapacity"; -import { requirePublisher, publicationCommand, recordPublicationRestriction } from "./_trustedPublication"; +import { requirePublisher, publicationCommand, legacyDeclarationCommand, recordPublicationRestriction } from "./_trustedPublication"; import { readScopedPagination, writeScopedPagination } from "./_reviewCursor"; import type { PaginationOptions } from "convex/server"; import { activeBatchAssignment, hash } from "./_mediaReview"; @@ -42,7 +42,7 @@ import { requireMcpAttributionText, requireSha256Hex } from "./_mcpWriteReceipts const OPEN_SUBMISSION_STATUSES = ["upload_pending", "submitted", "under_review"] as const; const submissionId = v.id("profileMediaSubmissions"); -const requestedPlacement = v.union(v.literal("profile_image"), v.literal("primary_logo")); +const requestedPlacement = v.union(v.literal("profile_image"), v.literal("primary_logo"), v.literal("gallery")); const reviewQueueStatus = v.union( v.literal("submitted"), v.literal("under_review"), @@ -230,7 +230,7 @@ export async function assertSubmissionRateLimits( export function assertEligibleTarget( profile: Doc<"profiles"> | null, - placement: "profile_image" | "primary_logo", + placement: "profile_image" | "primary_logo" | "gallery", ) { if ( profile === null || @@ -249,8 +249,8 @@ export function assertEligibleTarget( }); } if ( - (profile.profileType === "person" && placement !== "profile_image") || - (profile.profileType === "community" && placement !== "primary_logo") + placement !== "gallery" && ((profile.profileType === "person" && placement !== "profile_image") || + (profile.profileType === "community" && placement !== "primary_logo")) ) { throw new ConvexError({ code: "MEDIA_PLACEMENT_INVALID", @@ -279,6 +279,8 @@ function publicSubmission( : profile.displayName, profileIsPublic, profileType: profile.profileType, + requestKind: submission.requestKind, + candidateAssetId: submission.candidateAssetId, requestedPlacement: submission.requestedPlacement, status: submission.status, ...(submission.publicationMethod ? { publicationMethod: submission.publicationMethod } : {}), @@ -370,7 +372,7 @@ export const createUploadIntent = mutation({ if (!(await identityEmailVerified(ctx)) || user.email === undefined) { throw new ConvexError({ code: "MEDIA_EMAIL_UNVERIFIED", message: "Verify email" }); } - const profile = assertEligibleTarget(await ctx.db.get(args.profileId), args.requestedPlacement); + const profile = assertEligibleTarget(await ctx.db.get(args.profileId), "gallery"); if (profile.updatedAt !== args.expectedProfileUpdatedAt) { throw new ConvexError({ code: "MEDIA_PROFILE_CHANGED", message: "Refresh profile" }); } @@ -394,26 +396,18 @@ export const createUploadIntent = mutation({ message: "A source URL, file name, and credit are required.", }); } - const label = sanitizeProfileAssetLabel(args.label); + const label = sanitizeProfileAssetLabel(args.label) ?? "Image"; const altText = sanitizeProfileAssetAltText(args.altText); const creditUrl = sanitizeProfileAssetCreditUrl(args.creditUrl); const contributorNote = sanitizeNote(args.contributorNote, 500); - const targetPlacement = await ctx.db - .query("profileAssetPlacements") - .withIndex("by_profileId_placement_state_position", (query) => - query - .eq("profileId", profile._id) - .eq("placement", args.requestedPlacement) - .eq("state", "active"), - ) - .first(); const submissionId = await ctx.db.insert("profileMediaSubmissions", { profileId: profile._id, targetProfileSlug: profile.slug, targetProfileDisplayName: profile.displayName, submitterUserId: user._id, submitter: subject, - requestedPlacement: args.requestedPlacement, + requestKind: "kit_asset", + requestedPlacement: "gallery", originalFileName, sourceUrl, ...(label !== undefined ? { label } : {}), @@ -423,7 +417,7 @@ export const createUploadIntent = mutation({ ...(contributorNote !== undefined ? { contributorNote } : {}), status: "upload_pending", targetProfileUpdatedAt: profile.updatedAt, - ...(targetPlacement === null ? {} : { targetPlacementAssetId: targetPlacement.assetId }), + expiresAt: now + PROFILE_ASSET_UPLOAD_INTENT_TTL_MS, createdAt: now, updatedAt: now, @@ -433,6 +427,7 @@ export const createUploadIntent = mutation({ targetProfileId: profile._id, targetSubmissionId: submissionId, purpose: "community_proposal", + sourceUrl, originalFileName, mimeType: args.mimeType, byteSize: args.byteSize, @@ -440,7 +435,7 @@ export const createUploadIntent = mutation({ altText, credit, creditUrl, - placements: [args.requestedPlacement], + placements: ["gallery"], source: "community_submitted", now, }); @@ -642,7 +637,7 @@ export const prepareMcpMediaSubmission = internalMutation({ try { sourceUrl = normalizeProfileAssetSourceUrl(normalizeMcpSubmissionSourceUrl(args.sourceUrl)); credit = sanitizeProfileAssetCredit(args.credit); - label = sanitizeProfileAssetLabel(args.label); + label = sanitizeProfileAssetLabel(args.label) ?? "Image"; altText = sanitizeProfileAssetAltText(args.altText); creditUrl = sanitizeProfileAssetCreditUrl(args.creditUrl); } catch { @@ -652,15 +647,6 @@ export const prepareMcpMediaSubmission = internalMutation({ return await refuse("MCP_MEDIA_INPUT_INVALID"); } const contributorNote = sanitizeNote(args.contributorNote, 500); - const targetPlacement = await ctx.db - .query("profileAssetPlacements") - .withIndex("by_profileId_placement_state_position", (query) => - query - .eq("profileId", eligible._id) - .eq("placement", "profile_image") - .eq("state", "active"), - ) - .first(); const subject = mcpContributorSubject(actor._id); const submissionId = await ctx.db.insert("profileMediaSubmissions", { profileId: eligible._id, @@ -668,7 +654,8 @@ export const prepareMcpMediaSubmission = internalMutation({ targetProfileDisplayName: eligible.displayName, submitterUserId: actor._id, submitter: subject, - requestedPlacement: "profile_image", + requestKind: "kit_asset", + requestedPlacement: "gallery", sourceUrl, ...(label === undefined ? {} : { label }), ...(altText === undefined ? {} : { altText }), @@ -677,7 +664,7 @@ export const prepareMcpMediaSubmission = internalMutation({ ...(contributorNote === undefined ? {} : { contributorNote }), status: "upload_pending", targetProfileUpdatedAt: eligible.updatedAt, - ...(targetPlacement === null ? {} : { targetPlacementAssetId: targetPlacement.assetId }), + expiresAt: now + PROFILE_ASSET_UPLOAD_INTENT_TTL_MS, createdAt: now, updatedAt: now, @@ -692,7 +679,7 @@ export const prepareMcpMediaSubmission = internalMutation({ altText, credit, creditUrl, - placements: ["profile_image"], + placements: ["gallery"], source: "community_submitted", now, }); @@ -1364,6 +1351,8 @@ export const getCandidateForStorage = query({ }); const reviewProjectionFields = { + requestKind: v.optional(v.union(v.literal("kit_asset"), v.literal("identity_placement"))), + candidateAssetId: v.optional(v.id("profileAssets")), publicationMethod: v.optional(v.union(v.literal("trusted_publisher"), v.literal("independent_review"))), submissionId: v.id("profileMediaSubmissions"), profileId: v.id("profiles"), @@ -2500,7 +2489,7 @@ export const declarePublicationEvidence = mutation({ args: evidenceArgs, returns: receiptValidator, handler: async (ctx, args) => - publicationCommand(ctx, args, await browserReviewActor(ctx), true), + legacyDeclarationCommand(ctx, args, await browserReviewActor(ctx)), }); export const declarePublicationEvidenceForMcpActor = internalMutation({ args: { @@ -2517,7 +2506,7 @@ export const declarePublicationEvidenceForMcpActor = internalMutation({ oauthResource, ...args }, ) => - publicationCommand( + legacyDeclarationCommand( ctx, args, await trustedReviewActor(ctx, actorUserId, { @@ -2529,7 +2518,6 @@ export const declarePublicationEvidenceForMcpActor = internalMutation({ }, "assets:publish", ), - true, ), }); export const recordPublicationDispute = mutation({ diff --git a/convex/schema.ts b/convex/schema.ts index c8547d6b..162f8de6 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -923,6 +923,8 @@ export default defineSchema({ }).index("by_profileId_kind", ["profileId", "kind"]).index("by_contentSha256", ["contentSha256"]) .index("by_contentSha256_kind", ["contentSha256", "kind"]), profileMediaSubmissions: defineTable({ + requestKind: v.optional(v.union(v.literal("kit_asset"), v.literal("identity_placement"))), + candidateAssetId: v.optional(v.id("profileAssets")), publicationEvidenceId: v.optional(v.id("mediaPublicationEvidence")), publicationMethod: v.optional(v.union(v.literal("trusted_publisher"), v.literal("independent_review"))), publicationActorUserId: v.optional(v.id("users")), @@ -980,6 +982,7 @@ export default defineSchema({ .index("by_contentSha256_status", ["contentSha256", "status"]) .index("by_publicationMethod_actor", ["publicationMethod", "publicationActorUserId"]), profileAssets: defineTable({ + sourceSubmissionId: v.optional(v.id("profileMediaSubmissions")), profileId: v.id("profiles"), storageKey: v.string(), sourceStorageKey: v.optional(v.string()), diff --git a/docs/engineering/media-review-commands.md b/docs/engineering/media-review-commands.md index b40e9387..7f093618 100644 --- a/docs/engineering/media-review-commands.md +++ b/docs/engineering/media-review-commands.md @@ -54,17 +54,18 @@ expected review version, approve/reject, private reason, optional public reason, and an idempotency key. Rejection requires a public reason. Strict runtime contracts reject unknown fields and bound input strings. -The version hashes candidate/provenance, stored upload identity, current target, -current placement and asset, and the submission's optional `reviewRevision`. -Missing revisions mean zero for existing rows. Advisory start-review updates do -not invalidate it. Approval checks the original target and placement snapshots; a -fresh detail read does not silently rebase the proposal. Rejection remains -available against a refreshed version when that original placement has changed. +The version hashes candidate/provenance, stored upload identity, relevant target +state, and the submission's optional `reviewRevision`. Missing revisions mean +zero for existing rows. Advisory start-review updates do not invalidate it. +Kit publication excludes unrelated biography timestamps and existing picture +artwork. Placement inspection includes the current placement and asset. +Meaningful evidence changes require fresh inspection. Placement changes also +require an explicit rebase against the original selection snapshot. Successful publication and its immutable receipt commit in one transaction. Expected terminal refusals also persist receipts. Projected active-public-asset -capacity is checked before writes, accounting for singleton assets that retire -only when no other active placement remains. `capacity_exceeded` remains refused +capacity is checked before writes, with one new asset for each uploaded kit +contribution. `capacity_exceeded` remains refused when capacity later becomes available. The upload consumer retains its final transactional capacity assertion. Reusing a key with identical canonical input returns the original receipt; changed input returns @@ -167,36 +168,33 @@ authenticated end-to-end or provider-storage test. `trusted_publisher` is a separately issued, revocable account feature. Neither super-admin, reviewer, application tier nor contribution capacity implies it. -The browser uses `publisherDetail`, `declarePublicationEvidence`, and `publish`. -MCP uses `vrdex_media_submission_get`, `vrdex_media_submission_preview`, -`vrdex_media_submission_declare`, and `vrdex_media_submission_publish`. -Reads require `mcp:read` and `assets:publish`; declarations and publication require -`mcp:write` and `assets:publish`. These scopes are requestable, never defaults. -Every backend operation requires fresh verified email and the explicit grant. -Publisher projections are own-only and require a current public, published, -unclaimed target. They omit private reviewer reasons and moderator identities. -They do not confer independent reviewer authority. +The browser uses `publisherDetail` and `publish`. MCP publication reads require +`mcp:read` and `assets:publish`; publication requires `mcp:write` and +`assets:publish`. These scopes are requestable, never defaults. Every backend +operation requires fresh verified email and the explicit grant. Publisher +projections are own-only and require a public, published, unclaimed target. +They omit private reviewer reasons and moderator identities and do not confer +independent reviewer authority. Publication takes exactly `{submissionId, expectedReviewVersion, idempotencyKey}`. -Before publication, including for older pending proposals, the explicit declaration -command takes those same fields plus four booleans: `identityConfirmed`, -`attributionConfirmed`, `publicationPermitted`, and `noKnownRestrictions`. -Declarations are immutable records bound to the candidate digest, target, placement, -source reference or local provenance, and attribution. Recording one changes the -review version. Inspect again before publishing. Absent, false or stale declarations -require independent review. Nonempty credit or source text never implies consent -or identity confirmation. Upload completion only submits; it never publishes. -Choosing Independent review on the contributions page leaves the existing proposal -in its independent-review queue and closes the publication controls locally. - -The publication mutation reads the current target, both image/logo placements, -legacy image visibility, actual automatic artwork, declarations and restrictions -in one transaction. Review versions bind authored placements and cached source -identity/artwork records, including changes without an observed-time bump. Visible -VRChat icons, group logos and Discord artwork fill a slot. Disabled or hidden -fallbacks follow public rendering semantics. Existing authored placements remain -protected even when hidden. A target version or candidate change refuses the -inspected command. Conflicts preserve the independent-review path. +The explicit Publish command attaches the stored uploaded contribution to gallery. +Independent approval uses the same attachment path. Intake stores `kit_asset` +intent and gallery placement, including legacy picture/logo inputs. Owner upload +placement behavior is unchanged. Gallery titles use the sanitized submission label +or `Image`. Assets retain source, credit and immutable `sourceSubmissionId` +provenance. Upload completion submits the proposal; it never publishes. + +Legacy declaration endpoints remain for receipt compatibility. A same-key replay +returns its historical receipt. A fresh declaration returns `declaration_retired` +and creates no evidence. Existing evidence records remain untouched. + +Kit review versions bind the stored candidate and metadata, target identity, +privacy, claim state and applicable restrictions. Biography-only edits and +unrelated picture selection or automatic-artwork changes do not invalidate kit +inspection. Identity-placement snapshots continue to bind artwork and selection +provenance. Changed meaningful evidence requires a fresh inspection, with the +original stored candidate bytes preserved. Existing managed, private, legacy or +automatic pictures do not block additive kit publication or get replaced by it. Restriction history is indexed independently of mutable source URLs and collection item keys. Identity/dispute records restrict the target; rejection and suppression @@ -204,7 +202,7 @@ restrict the exact content digest across targets. Legacy rejected submissions an suppressed assets are checked by indexed digest as well. An unrelated digest is not refused solely because the target once had a rejection. Matching new history is linked through `priorRestrictionId`. Byte-different variants are not identified -perceptually; the explicit no-known-restrictions declaration remains required. +perceptually. The digest lookup considers only rejection and suppression records. An identity or dispute record for another target cannot block identical bytes on this target. Admin `recordPublicationDispute` records identity/dispute restrictions. Existing @@ -253,7 +251,7 @@ flowchart TD SignIn --> Account[/account/media-contributions] Account --> Own Own --> Withdraw[Versioned withdrawal] - Own --> Publisher[Separate publisher grant: inspect and declare] + Own --> Publisher[Separate publisher grant: inspect stored candidate] Publisher --> Publish[Explicit publish] OAuth --> Assigned[Discover assigned collections] Assigned --> Review[Authorized review queue] diff --git a/packages/api-contracts/src/media-review.ts b/packages/api-contracts/src/media-review.ts index b2a55fd8..106e0dd3 100644 --- a/packages/api-contracts/src/media-review.ts +++ b/packages/api-contracts/src/media-review.ts @@ -90,6 +90,8 @@ export const reviewSnapshotSchema = z.strictObject({ export type ReviewSnapshot = z.infer; export const reviewDetailSchema = z.strictObject({ + requestKind: z.enum(["kit_asset", "identity_placement"]).optional(), + candidateAssetId: boundedId.optional(), publicationMethod: z .enum(["trusted_publisher", "independent_review"]) .optional(), diff --git a/packages/api-contracts/tests/media-review.test.ts b/packages/api-contracts/tests/media-review.test.ts index dc395a21..fe7cc1d7 100644 --- a/packages/api-contracts/tests/media-review.test.ts +++ b/packages/api-contracts/tests/media-review.test.ts @@ -113,3 +113,14 @@ it("retains an indeterminate lost response and replays the original key without assert.equal(writes, 1); assert.equal(input.decisions[0]?.idempotencyKey, "stable"); }); + +import { reviewDetailSchema, mediaPublicationSchema } from "../src/media-review"; +it("describes additive and placement intent without adding assertions to publication", () => { + const intent = reviewDetailSchema.pick({ requestKind: true, candidateAssetId: true }); + assert.deepEqual(intent.parse({ requestKind: "kit_asset" }), { requestKind: "kit_asset" }); + assert.equal(intent.safeParse({ requestKind: "identity_placement", candidateAssetId: "asset" }).success, true); + assert.equal(intent.safeParse({ requestKind: "unknown" }).success, false); + const command = { submissionId: "submission", expectedReviewVersion: "version", idempotencyKey: "key" }; + assert.deepEqual(mediaPublicationSchema.parse(command), command); + assert.equal(mediaPublicationSchema.safeParse({ ...command, identityConfirmed: true }).success, false); +}); diff --git a/tests/backend/hosted-mcp-profile-media-contributions.test.ts b/tests/backend/hosted-mcp-profile-media-contributions.test.ts index 95aa20a0..63835ce0 100644 --- a/tests/backend/hosted-mcp-profile-media-contributions.test.ts +++ b/tests/backend/hosted-mcp-profile-media-contributions.test.ts @@ -376,7 +376,7 @@ describe("hosted MCP profile media contributions", () => { null, ); await seeded.t.run((ctx) => ctx.db.patch(seeded.profileId, { - fieldVisibility: { avatarImageUrl: "private" }, + fieldVisibility: { mediaKit: "private" }, })); const privateStatus = await seeded.t.query( internal.profileMediaSubmissions.listMcpMediaSubmissionsForActor, diff --git a/tests/backend/media-review-assignment.test.ts b/tests/backend/media-review-assignment.test.ts index 2e4275cf..14328bf2 100644 --- a/tests/backend/media-review-assignment.test.ts +++ b/tests/backend/media-review-assignment.test.ts @@ -515,7 +515,7 @@ it("refuses assigned approval of a private replacement with a stable receipt, in profileId: f.s.profileId, assetId: id, placement: "profile_image", position: 0, state: "active", updatedAt: NOW, }); - await ctx.db.patch(f.intent.submissionId, { targetPlacementAssetId: id }); + await ctx.db.patch(f.intent.submissionId, { requestKind: "identity_placement", requestedPlacement: "profile_image", targetPlacementAssetId: id }); return id; }); const detail = await f.actor.query(api.profileMediaSubmissions.reviewDetail, { @@ -772,6 +772,7 @@ it("manages assignments only as a verified super admin and refuses donor assignm }); it("placement changes require a new explicit rebase and previously viewed versions fail", async () => { const f = await fixture(); + await f.t.run(ctx => ctx.db.patch(f.intent.submissionId, { requestKind: "identity_placement", requestedPlacement: "profile_image" })); const args = { submissionId: f.intent.submissionId }; const before = await f.actor.query( api.profileMediaSubmissions.reviewDetail, diff --git a/tests/backend/media-review-authority.test.ts b/tests/backend/media-review-authority.test.ts index 99abda0d..ac3057c5 100644 --- a/tests/backend/media-review-authority.test.ts +++ b/tests/backend/media-review-authority.test.ts @@ -15,7 +15,7 @@ process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED = "true"; process.env.VRDEX_PROFILE_MEDIA_DIRECT_UPLOAD_ENABLED = "true"; process.env.VRDEX_PROFILE_MEDIA_KIT_ENABLED = "true"; -it("projects and versions the automatic image used by the public profile", async () => { +it("projects automatic artwork without versioning unrelated kit state", async () => { const t = convexTest({ schema, modules }); const seeded = await seed(t); const { intent } = await createAndUpload(t, seeded); @@ -69,7 +69,7 @@ it("projects and versions the automatic image used by the public profile", async }); assert.ok(after); assert.notEqual(after.currentAutomaticImageUrl, before.currentAutomaticImageUrl); - assert.notEqual(after.reviewVersion, before.reviewVersion); + assert.equal(after.reviewVersion, before.reviewVersion); }); it("projects owner evidence separately and rechecks revoked authority on replay", async () => { @@ -352,10 +352,10 @@ it("invalidates versions when candidate, placement or rebase evidence changes", idempotencyKey: change, }, ); - assert.equal(receipt.code, "review_changed", change); + assert.equal(receipt.code, ["candidate", "rebase"].includes(change) ? "review_changed" : undefined, change); assert.equal( (await t.run((ctx) => ctx.db.get(intent.submissionId)))?.status, - "submitted", + ["candidate", "rebase"].includes(change) ? "submitted" : "approved", ); } }); diff --git a/tests/backend/profile-media-submissions.test.ts b/tests/backend/profile-media-submissions.test.ts index f7318c78..88db5426 100644 --- a/tests/backend/profile-media-submissions.test.ts +++ b/tests/backend/profile-media-submissions.test.ts @@ -128,13 +128,14 @@ describe("unclaimed-profile media submissions", () => { assert.equal(after.asset?.altText, "Reviewed portrait of Community DJ."); assert.equal(after.asset?.credit, "Reviewed artist credit"); assert.equal(after.asset?.creditUrl, "https://artist.example/credits"); - assert.equal(after.placements[0]?.placement, "profile_image"); + assert.equal(after.placements[0]?.placement, "gallery"); assert.equal(after.submission?.approvedAssetId, decision.assetId); const publicProfile = await t.query(api.profileAssets.listPublicBySlug, { slug: "community-dj", }); - assert.equal(publicProfile?.mediaKit.profileImage?.assetId, decision.assetId); + assert.equal(publicProfile?.mediaKit.profileImage, undefined); + assert.equal(publicProfile?.mediaKit.galleryAssets[0]?.assetId, decision.assetId); assert.notEqual( await t.query(api.profileAssets.getPublicAssetForStorage, { slug: "community-dj", @@ -709,18 +710,16 @@ describe("unclaimed-profile media submissions", () => { }); }); - await assert.rejects( - t.withIdentity(seeded.moderatorIdentity).mutation(api.profileMediaSubmissions.decide, { - submissionId: intent.submissionId, - decision: "approve", - expectedProfileUpdatedAt: NOW, - privateReason: "Would replace newer owner media.", - }), - /media placement changed/i, - ); + const decision = await t.withIdentity(seeded.moderatorIdentity).mutation(api.profileMediaSubmissions.decide, { + submissionId: intent.submissionId, decision: "approve", expectedProfileUpdatedAt: NOW, privateReason: "Add to kit.", + }); + assert.equal(decision.status, "approved"); + const placements = await t.run(ctx => ctx.db.query("profileAssetPlacements").collect()); + assert.equal(placements.filter(p => p.placement === "profile_image" && p.state === "active").length, 1); + assert.equal(placements.filter(p => p.placement === "gallery").length, 1); }); - it("retires the replaced singleton asset instead of exposing it as unplaced media", async () => { + it("does not retire existing deleted artwork when adding to the kit", async () => { const t = convexTest({ schema, modules }); const seeded = await seed(t); const oldAssetId = await t.run(async (ctx) => { @@ -775,7 +774,7 @@ describe("unclaimed-profile media submissions", () => { assert.equal(decision.status, "approved"); const retiredAsset = await t.run((ctx) => ctx.db.get(oldAssetId)); assert.equal(retiredAsset?.state, "deleted"); - assert.equal(retiredAsset?.retiredAt !== undefined, true); + assert.equal(retiredAsset?.retiredAt, undefined); assert.equal( await t.query(api.profileAssets.getPublicAssetForStorage, { slug: "community-dj", @@ -812,19 +811,11 @@ describe("unclaimed-profile media submissions", () => { const ownedProfiles = await owner.query(api.profileAssets.listOwnedMediaKitProfiles, {}); assert.equal( ownedProfiles?.[0]?.assets.some((asset) => asset.assetId === oldAssetId), - false, - ); - await assert.rejects( - owner.mutation(api.profileAssets.setOwnedAssetDeleted, { - profileId: seeded.profileId, - assetId: oldAssetId, - deleted: false, - }), - /not found/i, + true, ); }); - it("approves a singleton replacement when the profile already has twelve active assets", async () => { + it("refuses additive kit publication when twelve active assets use capacity", async () => { const t = convexTest({ schema, modules }); const seeded = await seed(t); await t.run(async (ctx) => { @@ -863,7 +854,7 @@ describe("unclaimed-profile media submissions", () => { submissionId: intent.submissionId, expectedReviewVersion: detail.reviewVersion, decision: "approve", privateReason: "Replacement approved.", idempotencyKey: "replacement-at-capacity", }); - assert.equal(receipt.operationState, "committed"); + assert.equal(receipt.code, "capacity_exceeded"); const activeAssets = await t.run((ctx) => ctx.db @@ -1378,7 +1369,7 @@ describe("shared review receipts", () => { ); assert.ok(detail); await t.run((ctx) => - ctx.db.patch(seeded.profileId, { updatedAt: NOW + 1 }), + ctx.db.patch(seeded.profileId, { displayName: "Changed identity", updatedAt: NOW + 1 }), ); const command = { submissionId: intent.submissionId, @@ -1392,7 +1383,7 @@ describe("shared review receipts", () => { command, ); assert.equal(receipt.code, "review_changed"); - await t.run((ctx) => ctx.db.patch(seeded.profileId, { updatedAt: NOW })); + await t.run((ctx) => ctx.db.patch(seeded.profileId, { displayName: "Community DJ", updatedAt: NOW })); assert.deepEqual( await reviewer.mutation( api.profileMediaSubmissions.decideWithReceipt, diff --git a/tests/backend/trusted-publication.test.ts b/tests/backend/trusted-publication.test.ts index 16d13729..5c227f87 100644 --- a/tests/backend/trusted-publication.test.ts +++ b/tests/backend/trusted-publication.test.ts @@ -36,22 +36,6 @@ async function fixture() { actor.query(api.profileMediaSubmissions.publisherDetail, { submissionId: intent.submissionId, }); - const declare = async () => { - const before = await detail(); - assert.ok(before); - await actor.mutation( - api.profileMediaSubmissions.declarePublicationEvidence, - { - submissionId: intent.submissionId, - expectedReviewVersion: before.reviewVersion, - identityConfirmed: true, - attributionConfirmed: true, - publicationPermitted: true, - noKnownRestrictions: true, - idempotencyKey: crypto.randomUUID(), - }, - ); - }; const publish = async () => { const before = await detail(); assert.ok(before); @@ -61,47 +45,43 @@ async function fixture() { idempotencyKey: crypto.randomUUID(), }); }; - return { t, s, intent, grantId, actor, detail, declare, publish }; + return { t, s, intent, grantId, actor, detail, publish }; } -it("requires an explicit declaration and records own publication without inventing review", async () => { +it("publishes into the kit without declarations or inventing review", async () => { const f = await fixture(); - assert.equal((await f.publish()).code, "independent_review_required"); - await f.declare(); assert.equal((await f.publish()).operationState, "committed"); const row = await f.t.run((ctx) => ctx.db.get(f.intent.submissionId)); assert.equal(row?.publicationMethod, "trusted_publisher"); assert.equal(row?.reviewer, undefined); assert.ok(row?.publicationOperationId); + const assets = await f.t.run((ctx) => ctx.db.query("profileAssets").collect()); + assert.equal(assets[0]?.sourceSubmissionId, f.intent.submissionId); + assert.equal(assets[0]?.label, "Image"); + assert.equal(assets[0]?.sourceUrl, "https://artist.example/press"); + assert.ok((await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()))?.publishedBytes); + assert.deepEqual((await f.t.run((ctx) => ctx.db.query("profileAssetPlacements").collect())).map(p => p.placement), ["gallery"]); + assert.equal((await f.t.run((ctx) => ctx.db.query("mediaPublicationEvidence").collect())).length, 0); assert.equal((await f.detail())?.publicationMethod, "trusted_publisher"); }); for (const scenario of [ - "legacy", "claimed", "missing_credit", "dispute", "suppression", "rejection", "revoked", - "changed", ] as const) { it(`refuses trusted publication: ${scenario}`, async () => { const f = await fixture(); - await f.declare(); const before = await f.detail(); assert.ok(before); await f.t.run(async (ctx) => { - if (scenario === "legacy") - await ctx.db.patch(f.s.profileId, { - avatarImageUrl: "https://example.test/old.png", - }); if (scenario === "claimed") await ctx.db.patch(f.s.profileId, { claimState: "claimed_verified" }); if (scenario === "missing_credit") await ctx.db.patch(f.intent.submissionId, { credit: "" }); if (scenario === "revoked") await ctx.db.patch(f.grantId, { state: "revoked" }); - if (scenario === "changed") - await ctx.db.patch(f.s.profileId, { updatedAt: Date.now() }); if (["dispute", "suppression", "rejection"].includes(scenario)) await ctx.db.insert("mediaPublicationRestrictions", { profileId: f.s.profileId, @@ -153,13 +133,6 @@ it("separates the publisher grant from admin and reviewer authority", () => { ownSubmission: true, sourceRecorded: true, credit: "Photographer", - identityConfirmed: true, - attributionConfirmed: true, - publicationPermitted: true, - noKnownRestrictions: true, - currentPlacement: null, - legacyImageUrl: null, - automaticImageUrl: null, priorSuppression: false, priorRejection: false, unresolvedDispute: false, @@ -167,15 +140,9 @@ it("separates the publisher grant from admin and reviewer authority", () => { assert.equal(eligible(facts), true); for (const patch of [ { publisherGrant: false, independentReviewerGrant: true }, - { identityConfirmed: false }, - { attributionConfirmed: false }, { sourceRecorded: false }, - { publicationPermitted: false }, - { noKnownRestrictions: false }, { ownSubmission: false }, { publicUnclaimed: false }, - { currentPlacement: {} }, - { legacyImageUrl: "https://example.test/old.png" }, ]) { assert.equal(eligible({ ...facts, ...patch }), false); } @@ -210,7 +177,6 @@ it("publishes a community logo, preserves independent self-review refusal, and r updatedAt: Date.now(), }); }); - await f.declare(); const detail = await f.detail(); assert.ok(detail); assert.equal( @@ -293,7 +259,7 @@ it("confines publisher-only detail and candidate to own public unclaimed submiss ); }); for (const kind of ["vrchat_user", "vrchat_group", "discord_guild"] as const) { - it(`refuses rendered ${kind} automatic artwork and versions cache changes`, async () => { + it(`publishes beside rendered ${kind} automatic artwork without invalidating kit inspection`, async () => { const f = await fixture(); const locator = kind === "vrchat_user" @@ -350,10 +316,8 @@ for (const kind of ["vrchat_user", "vrchat_group", "discord_guild"] as const) { observedAt: 1, }); }); - await f.declare(); const before = await f.detail(); assert.ok(before?.currentAutomaticImageUrl); - assert.equal((await f.publish()).code, "independent_review_required"); await f.t.run((ctx) => ctx.db.patch(destinationId, { artworkSourceUrl: "https://example.test/changed.png", @@ -361,7 +325,7 @@ for (const kind of ["vrchat_user", "vrchat_group", "discord_guild"] as const) { ); const after = await f.detail(); assert.ok(after); - assert.notEqual(after.reviewVersion, before.reviewVersion); + assert.equal(after.reviewVersion, before.reviewVersion); await f.t.run((ctx) => ctx.db.patch(f.s.profileId, { imageFallback: { disabled: true } }), ); @@ -371,7 +335,6 @@ for (const kind of ["vrchat_user", "vrchat_group", "discord_guild"] as const) { } it("keeps unrelated rejection eligible but prevents fresh URLs and item keys evading known content", async () => { const f = await fixture(); - await f.declare(); await f.t.run((ctx) => ctx.db.insert("mediaPublicationRestrictions", { profileId: f.s.profileId, @@ -384,7 +347,6 @@ it("keeps unrelated rejection eligible but prevents fresh URLs and item keys eva ); assert.equal((await f.publish()).operationState, "committed"); const g = await fixture(); - await g.declare(); await g.t.run(async (ctx) => { const row = await ctx.db.get(g.intent.submissionId); assert.ok(row); @@ -401,8 +363,7 @@ it("keeps unrelated rejection eligible but prevents fresh URLs and item keys eva for (const kind of ["identity", "dispute"] as const) { it(`does not apply another profile's ${kind} restriction to identical bytes`, async () => { const f = await fixture(); - await f.declare(); - await f.t.run(async (ctx) => { + await f.t.run(async (ctx) => { const target = (await ctx.db.get(f.s.profileId))!; const { _id, _creationTime, ...fields } = target; const otherProfileId = await ctx.db.insert("profiles", { @@ -424,8 +385,7 @@ for (const kind of ["identity", "dispute"] as const) { for (const kind of ["rejection", "suppression"] as const) { it(`applies another profile's ${kind} restriction to identical bytes`, async () => { const f = await fixture(); - await f.declare(); - await f.t.run(async (ctx) => { + await f.t.run(async (ctx) => { const target = (await ctx.db.get(f.s.profileId))!; const { _id, _creationTime, ...fields } = target; const otherProfileId = await ctx.db.insert("profiles", { @@ -444,14 +404,14 @@ for (const kind of ["rejection", "suppression"] as const) { assert.equal((await f.publish()).code, "independent_review_required"); }); } -it("invalidates declarations on changed credit or source and records correction lineage", async () => { +it("invalidates inspection on changed credit and records correction lineage", async () => { const f = await fixture(); - await f.declare(); + const before = await f.detail(); + assert.ok(before); await f.t.run((ctx) => ctx.db.patch(f.intent.submissionId, { credit: "Different attribution" }), ); - assert.equal((await f.publish()).code, "independent_review_required"); - await f.declare(); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.publish, { submissionId: f.intent.submissionId, expectedReviewVersion: before.reviewVersion, idempotencyKey: "stale-credit" })).code, "review_changed"); const published = await f.publish(); assert.equal(published.operationState, "committed"); await f.t @@ -465,9 +425,8 @@ it("invalidates declarations on changed credit or source and records correction ); assert.equal(restriction?.correctionOfOperationId, published.operationId); }); -it("fresh transaction refuses concurrent slot fill and independent review can still approve legacy evidence", async () => { +it("fresh transaction adds to a nonempty kit and independent review can still approve legacy evidence", async () => { const f = await fixture(); - await f.declare(); const before = await f.detail(); assert.ok(before); const command = { @@ -501,9 +460,8 @@ it("fresh transaction refuses concurrent slot fill and independent review can st assert.equal( (await f.actor.mutation(api.profileMediaSubmissions.publish, command)) .operationState, - "refused", + "committed", ); - assert.equal((await f.publish()).code, "independent_review_required"); const row = await f.t.run((ctx) => ctx.db.get(competitor.submissionId)); assert.equal(row?.publicationMethod, "independent_review"); assert.ok(row?.publicationOperationId); @@ -530,7 +488,7 @@ it("fresh transaction refuses concurrent slot fill and independent review can st ); }); -it("records explicit unresolved declarations and local provenance without inferring truth from credit", async () => { +it("retires fresh legacy declarations without evidence and accepts local provenance", async () => { const f = await fixture(); await f.t.run((ctx) => ctx.db.patch(f.intent.submissionId, { @@ -541,7 +499,7 @@ it("records explicit unresolved declarations and local provenance without inferr ); const before = await f.detail(); assert.ok(before); - await f.actor.mutation( + const retired = await f.actor.mutation( api.profileMediaSubmissions.declarePublicationEvidence, { submissionId: f.intent.submissionId, @@ -553,13 +511,12 @@ it("records explicit unresolved declarations and local provenance without inferr idempotencyKey: "uncertain", }, ); - assert.equal((await f.publish()).code, "independent_review_required"); - await f.declare(); + assert.equal(retired.code, "declaration_retired"); + assert.equal((await f.t.run(ctx => ctx.db.query("mediaPublicationEvidence").collect())).length, 0); assert.equal((await f.publish()).operationState, "committed"); }); it("samples publisher publications with resource-bound pagination and omits legacy approvals", async () => { const f = await fixture(); - await f.declare(); const published = await f.publish(); const admin = f.t.withIdentity(f.s.moderatorIdentity); const page = await admin.query( @@ -586,3 +543,74 @@ it("samples publisher publications with resource-bound pagination and omits lega /Super admin/, ); }); + +for (const change of ["biography", "identity", "metadata", "privacy", "restriction"] as const) { + it(`kit freshness responds to ${change}`, async () => { + const f = await fixture(); + const before = await f.detail(); + assert.ok(before); + await f.t.run(async (ctx) => { + if (change === "biography") await ctx.db.patch(f.s.profileId, { bio: "Updated biography", updatedAt: Date.now() }); + if (change === "identity") await ctx.db.patch(f.s.profileId, { displayName: "Changed identity", updatedAt: Date.now() }); + if (change === "metadata") await ctx.db.patch(f.intent.submissionId, { credit: "Changed credit" }); + if (change === "privacy") await ctx.db.patch(f.s.profileId, { publicSurfacingState: "opted_out" }); + if (change === "restriction") await ctx.db.insert("mediaPublicationRestrictions", { profileId: f.s.profileId, submissionId: f.intent.submissionId, kind: "dispute", actorUserId: f.s.moderatorUserId, createdAt: Date.now() }); + }); + if (change !== "privacy") { + const after = await f.detail(); + assert.ok(after); + assert.equal(after.reviewVersion === before.reviewVersion, change === "biography"); + const receipt = await f.actor.mutation(api.profileMediaSubmissions.publish, { submissionId: f.intent.submissionId, expectedReviewVersion: before.reviewVersion, idempotencyKey: change }); + assert.equal(receipt.operationState, change === "biography" ? "committed" : "refused"); + } else await assert.rejects(f.publish(), /publication access/); + }); +} + +for (const visibility of ["public", "private"] as const) { + it(`adds gallery media without changing ${visibility} managed picture selection`, async () => { + const f = await fixture(); + const oldAssetId = await f.t.run(async ctx => { + const assetId = await ctx.db.insert("profileAssets", { + profileId: f.s.profileId, storageKey: "existing.webp", mimeType: "image/webp", byteSize: 100, + label: "Existing", visibility, source: "owner_authored", uploadedBy: { issuer: "test", subject: "owner", tokenIdentifier: "owner" }, + uploadedAt: Date.now(), state: "active", updatedAt: Date.now(), + }); + for (const placement of ["profile_image", "gallery"] as const) await ctx.db.insert("profileAssetPlacements", { + profileId: f.s.profileId, assetId, placement, position: 0, state: "active", updatedAt: Date.now(), + }); + return assetId; + }); + const before = await f.detail(); assert.ok(before); + const command = { submissionId: f.intent.submissionId, expectedReviewVersion: before.reviewVersion, idempotencyKey: visibility }; + const receipt = await f.actor.mutation(api.profileMediaSubmissions.publish, command); + assert.equal(receipt.operationState, "committed"); + assert.deepEqual(await f.actor.mutation(api.profileMediaSubmissions.publish, command), receipt); + const placements = await f.t.run(ctx => ctx.db.query("profileAssetPlacements").collect()); + assert.equal(placements.find(p => p.placement === "profile_image" && p.state === "active")?.assetId, oldAssetId); + assert.equal(placements.filter(p => p.placement === "gallery" && p.state === "active").length, 2); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").collect())).length, 2); + assert.equal((await f.t.run(ctx => ctx.db.query("mediaPublicationEvidence").collect())).length, 0); + }); +} +it("replays historical declaration receipts without rewriting legacy evidence", async () => { + const f = await fixture(); const detail = await f.detail(); assert.ok(detail); + const args = { submissionId: f.intent.submissionId, expectedReviewVersion: detail.reviewVersion, identityConfirmed: true, attributionConfirmed: true, publicationPermitted: true, noKnownRestrictions: true, idempotencyKey: "historical" }; + const receipt = { operationId: "old-operation", operationState: "committed" as const, resourceId: f.intent.submissionId }; + const { hash } = await import("../../convex/_mediaReview"); + const { publicationEvidenceSchema } = await import("../../packages/api-contracts/src/media-review"); + await f.t.run(async ctx => { + const evidenceId = await ctx.db.insert("mediaPublicationEvidence", { submissionId: f.intent.submissionId, actorUserId: f.s.contributorUserId, candidateVersion: "historical", identityConfirmed: true, attributionConfirmed: true, publicationPermitted: true, noKnownRestrictions: true, createdAt: 1 }); + await ctx.db.patch(f.intent.submissionId, { publicationEvidenceId: evidenceId }); + await ctx.db.insert("mediaReviewReceipts", { actorUserId: f.s.contributorUserId, idempotencyKey: args.idempotencyKey, inputHash: await hash({ command: "declare", ...publicationEvidenceSchema.parse(args) }), submissionId: f.intent.submissionId, receipt, createdAt: 1 }); + }); + assert.deepEqual(await f.actor.mutation(api.profileMediaSubmissions.declarePublicationEvidence, args), receipt); + assert.equal((await f.t.run(ctx => ctx.db.query("mediaPublicationEvidence").collect())).length, 1); +}); + +it("publishes kit media beside a visible legacy picture", async () => { + const f = await fixture(); + await f.t.run(ctx => ctx.db.patch(f.s.profileId, { avatarImageUrl: "https://example.test/legacy.webp" })); + assert.equal((await f.publish()).operationState, "committed"); + assert.equal((await f.t.run(ctx => ctx.db.get(f.s.profileId)))?.avatarImageUrl, "https://example.test/legacy.webp"); + assert.deepEqual((await f.t.run(ctx => ctx.db.query("profileAssetPlacements").collect())).map(p => p.placement), ["gallery"]); +}); From b7d82ee208d2b4b6ca8c822fca99d3e47f76fc72 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Fri, 2 Oct 2026 03:30:07 -0400 Subject: [PATCH 06/28] fix: bind media inspection to external identity links --- convex/_mediaReview.ts | 5 +++++ tests/backend/media-review-authority.test.ts | 13 ++++++++++++ tests/backend/trusted-publication.test.ts | 21 ++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/convex/_mediaReview.ts b/convex/_mediaReview.ts index 871e3c4f..9737a0ac 100644 --- a/convex/_mediaReview.ts +++ b/convex/_mediaReview.ts @@ -29,6 +29,7 @@ import { } from "./_identity"; import { recordPublicationRestriction } from "./_trustedPublication"; import { isProfileFieldVisible } from "./_profileFieldVisibility"; +import { parseProfileLinkDestination } from "./_profileLinkDestination"; import { automaticProfileImage, profileImageSources, @@ -541,6 +542,10 @@ export async function reviewSnapshot( displayName: profile.displayName, claimState: profile.claimState, publicationState: profile.publicationState, publicSurfacingState: profile.publicSurfacingState, fieldVisibility: profile.fieldVisibility, + identityDestinations: (profile.outboundLinks ?? []).flatMap(link => { + const destination = parseProfileLinkDestination(link); + return destination ? [destination.key] : []; + }).sort(), } : profile, placement: kitAsset ? undefined : placement, currentAsset: kitAsset ? undefined : currentAsset, diff --git a/tests/backend/media-review-authority.test.ts b/tests/backend/media-review-authority.test.ts index ac3057c5..7ff22894 100644 --- a/tests/backend/media-review-authority.test.ts +++ b/tests/backend/media-review-authority.test.ts @@ -70,6 +70,19 @@ it("projects automatic artwork without versioning unrelated kit state", async () assert.ok(after); assert.notEqual(after.currentAutomaticImageUrl, before.currentAutomaticImageUrl); assert.equal(after.reviewVersion, before.reviewVersion); + await t.run(ctx => ctx.db.patch(seeded.profileId, { + outboundLinks: [{ type: "vrchat_profile", label: "VRChat", source: "community_submitted", + url: "https://vrchat.com/home/user/usr_7023d326-083f-41fe-a3e9-27ea303b50c6" }], + })); + const changed = await actor.query(api.profileMediaSubmissions.reviewDetail, { submissionId: intent.submissionId }); + assert.ok(changed); + assert.notEqual(changed.reviewVersion, after.reviewVersion); + const receipt = await actor.mutation(api.profileMediaSubmissions.decideWithReceipt, { + submissionId: intent.submissionId, expectedReviewVersion: after.reviewVersion, decision: "approve", + privateReason: "Inspected former identity", idempotencyKey: "old-linked-identity", + }); + assert.equal(receipt.code, "review_changed"); + assert.equal((await t.run(ctx => ctx.db.get(intent.submissionId)))?.status, "submitted"); }); it("projects owner evidence separately and rechecks revoked authority on replay", async () => { diff --git a/tests/backend/trusted-publication.test.ts b/tests/backend/trusted-publication.test.ts index 5c227f87..4eb39843 100644 --- a/tests/backend/trusted-publication.test.ts +++ b/tests/backend/trusted-publication.test.ts @@ -614,3 +614,24 @@ it("publishes kit media beside a visible legacy picture", async () => { assert.equal((await f.t.run(ctx => ctx.db.get(f.s.profileId)))?.avatarImageUrl, "https://example.test/legacy.webp"); assert.deepEqual((await f.t.run(ctx => ctx.db.query("profileAssetPlacements").collect())).map(p => p.placement), ["gallery"]); }); + +for (const kind of ["vrchat_user", "vrchat_group", "discord_guild"] as const) { + it(`requires fresh kit inspection after replacing linked ${kind} identity`, async () => { + const f = await fixture(); + const url = (suffix: string) => kind === "discord_guild" + ? `https://discord.gg/Club${suffix}` + : `https://vrchat.com/home/${kind === "vrchat_user" ? "user/usr" : "group/grp"}_7023d326-083f-41fe-a3e9-27ea303b50c${suffix}`; + const link = { type: "website" as const, label: "Identity", source: "community_submitted" as const }; + await f.t.run(ctx => ctx.db.patch(f.s.profileId, { outboundLinks: [{ ...link, url: url("5") }] })); + const before = await f.detail(); assert.ok(before); + await f.t.run(ctx => ctx.db.patch(f.s.profileId, { outboundLinks: [{ ...link, url: url("6") }] })); + const after = await f.detail(); assert.ok(after); + assert.notEqual(after.reviewVersion, before.reviewVersion); + const receipt = await f.actor.mutation(api.profileMediaSubmissions.publish, { + submissionId: f.intent.submissionId, expectedReviewVersion: before.reviewVersion, idempotencyKey: kind, + }); + assert.equal(receipt.code, "review_changed"); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").collect())).length, 0); + assert.equal((await f.publish()).operationState, "committed"); + }); +} From 134e1089ccd77b42c40bd4d946c53191fa89aacc Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 5 Oct 2026 18:52:41 -0400 Subject: [PATCH 07/28] feat: separate profile picture selection from publication --- convex/_generated/api.d.ts | 2 + convex/_mediaContributionCommands.ts | 110 +++++++ convex/_mediaReview.ts | 71 ++++- convex/_profileAssets.ts | 37 +++ convex/_trustedPublication.ts | 2 + convex/profileAssets.ts | 9 + convex/profileMediaSubmissions.ts | 45 ++- convex/schema.ts | 3 + docs/engineering/media-review-commands.md | 23 ++ packages/api-contracts/src/media-review.ts | 23 ++ .../api-contracts/tests/media-review.test.ts | 20 ++ .../media-contribution-commands.test.ts | 271 ++++++++++++++++++ tests/backend/profile-foundation.test.ts | 5 + 13 files changed, 608 insertions(+), 13 deletions(-) create mode 100644 convex/_mediaContributionCommands.ts create mode 100644 tests/backend/media-contribution-commands.test.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 4c41d5dc..c150cf6e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -71,6 +71,7 @@ import type * as _localFixtures from "../_localFixtures.js"; import type * as _mcpToolEvents from "../_mcpToolEvents.js"; import type * as _mcpWriteReceipts from "../_mcpWriteReceipts.js"; import type * as _mediaCleanupUrl from "../_mediaCleanupUrl.js"; +import type * as _mediaContributionCommands from "../_mediaContributionCommands.js"; import type * as _mediaReview from "../_mediaReview.js"; import type * as _oauth from "../_oauth.js"; import type * as _oauthConsentTransactions from "../_oauthConsentTransactions.js"; @@ -259,6 +260,7 @@ declare const fullApi: ApiFromModules<{ _mcpToolEvents: typeof _mcpToolEvents; _mcpWriteReceipts: typeof _mcpWriteReceipts; _mediaCleanupUrl: typeof _mediaCleanupUrl; + _mediaContributionCommands: typeof _mediaContributionCommands; _mediaReview: typeof _mediaReview; _oauth: typeof _oauth; _oauthConsentTransactions: typeof _oauthConsentTransactions; diff --git a/convex/_mediaContributionCommands.ts b/convex/_mediaContributionCommands.ts new file mode 100644 index 00000000..6ad9b5fe --- /dev/null +++ b/convex/_mediaContributionCommands.ts @@ -0,0 +1,110 @@ +import { ConvexError } from "convex/values"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { getAccountFeatureAccess } from "./_accountFeatures"; +import { assertReviewActorVerified, hash, reviewSnapshot, type ReviewActor } from "./_mediaReview"; +import { selectProfileAssetIdentity, PROFILE_MEDIA_SUBMISSION_RETENTION_MS } from "./_profileAssets"; +import { effectiveContributionPolicy } from "./_contributionCapacity"; +import { assertSubmissionRateLimits, openSubmissionCountForUser, openSubmissionCountForProfile } from "./profileMediaSubmissions"; +import { + contributionCommandBaseSchema, contributionPlacementCommandSchema, + type ContributionCommandBase, type ContributionPlacementCommand, + type PublishedContributionDetail, type CommandReceipt, +} from "../packages/api-contracts/src/media-review"; + +async function contributionState(ctx: QueryCtx | MutationCtx, submissionId: string, actor: ReviewActor) { + assertReviewActorVerified(actor); + const id = ctx.db.normalizeId("profileMediaSubmissions", submissionId); + const submission = id ? await ctx.db.get(id) : null; + const profile = submission ? await ctx.db.get(submission.profileId) : null; + const asset = submission?.approvedAssetId ? await ctx.db.get(submission.approvedAssetId) : null; + if (!submission || !profile || !asset || submission.submitterUserId !== actor.user._id || + submission.status !== "approved" || submission.requestKind === "identity_placement" || + asset.sourceSubmissionId !== submission._id || asset.profileId !== profile._id || + asset.state !== "active" || asset.visibility !== "public" || asset.retiredAt !== undefined || asset.moderatorSuppressedAt !== undefined) + throw new ConvexError({ code: "MEDIA_RESOURCE_UNAVAILABLE", message: "Published contribution unavailable." }); + const placement = profile.profileType === "person" ? "profile_image" as const : "primary_logo" as const; + const snapshot = await reviewSnapshot(ctx, { ...submission, requestKind: "identity_placement", candidateAssetId: asset._id, requestedPlacement: placement }, profile); + const identityPlacements = await ctx.db.query("profileAssetPlacements").withIndex("by_profileId_state", q => q.eq("profileId", profile._id).eq("state", "active")) + .filter(q => q.or(q.eq(q.field("placement"), "profile_image"), q.eq(q.field("placement"), "primary_logo"))).collect(); + const current = await ctx.db.query("profileAssetPlacements").withIndex("by_profileId_placement_state_position", q => + q.eq("profileId", profile._id).eq("placement", placement).eq("state", "active")).first(); + const access = await getAccountFeatureAccess(ctx.db, actor.user._id); + const restrictions = await Promise.all([ + ctx.db.query("mediaPublicationRestrictions").withIndex("by_profileId_kind", q => q.eq("profileId", profile._id).eq("kind", "identity")).first(), + ctx.db.query("mediaPublicationRestrictions").withIndex("by_profileId_kind", q => q.eq("profileId", profile._id).eq("kind", "dispute")).first(), + asset.contentSha256 ? ctx.db.query("mediaPublicationRestrictions").withIndex("by_contentSha256_kind", q => q.eq("contentSha256", asset.contentSha256).eq("kind", "suppression")).first() : null, + asset.contentSha256 ? ctx.db.query("mediaPublicationRestrictions").withIndex("by_contentSha256_kind", q => q.eq("contentSha256", asset.contentSha256).eq("kind", "rejection")).first() : null, + ]); + const publicUnclaimed = profile.claimState === "unclaimed" && profile.publicationState === "published" && profile.publicSurfacingState === "public"; + const publisher = access.canPublishMedia && publicUnclaimed && !restrictions.some(Boolean); + const ownSelection = !!current && current.assetId === asset._id && current.selectionActorUserId === actor.user._id && + current.selectionOperationId?.startsWith("contribution-primary:") === true; + return { submission, profile, asset, placement, snapshot, current, identityPlacements, publisher, publicUnclaimed, restrictions, ownSelection, + version: await hash({ asset, source: submission, placement: current, picture: snapshot.currentImage, restrictions, publicUnclaimed, revision: snapshot.reviewVersion }) }; +} + +export async function publishedContributionDetail(ctx: QueryCtx | MutationCtx, submissionId: string, actor: ReviewActor): Promise { + const s = await contributionState(ctx, submissionId, actor); + const ownKitOnly = s.publicUnclaimed && !s.restrictions.some(Boolean) && + !s.identityPlacements.some(row => row.assetId === s.asset._id); + return { + submissionId: s.submission._id, assetId: s.asset._id, profileId: s.profile._id, profileSlug: s.profile.slug, + contributionVersion: s.version, + metadata: { label: s.asset.label ?? "Image", altText: s.asset.altText, credit: s.asset.credit ?? "", + creditUrl: s.asset.creditUrl, sourceUrl: s.asset.sourceUrl, sourceDescription: s.submission.sourceDescription }, + canSelectPrimary: s.publisher && !s.identityPlacements.length && !s.snapshot.currentImage, + canClearPrimary: s.publisher && s.ownSelection, + canEditMetadata: ownKitOnly, canRemove: ownKitOnly && s.submission.legalHoldAt === undefined, + }; +} + +async function command(ctx: MutationCtx, input: ContributionCommandBase | ContributionPlacementCommand, actor: ReviewActor, proposal: boolean): Promise { + const args = proposal ? contributionCommandBaseSchema.parse(input) : contributionPlacementCommandSchema.parse(input); + const s = await contributionState(ctx, args.submissionId, actor); + const inputHash = await hash({ command: proposal ? "propose_placement" : "place_contribution", ...args }); + const previous = await ctx.db.query("mediaReviewReceipts").withIndex("by_actorUserId_idempotencyKey", q => + q.eq("actorUserId", actor.user._id).eq("idempotencyKey", args.idempotencyKey)).unique(); + if (previous) { + if (!s.publicUnclaimed || (!proposal && !s.publisher) || s.restrictions.some(Boolean)) + return { operationId: previous.receipt.operationId, operationState: "refused", code: "authority_changed" }; + return previous.inputHash === inputHash ? previous.receipt : { operationId: previous.receipt.operationId, operationState: "refused", code: "idempotency_conflict" }; + } + const selecting = "action" in args && args.action === "select_primary"; + let code = process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED !== "true" ? "review_disabled" : + !s.publicUnclaimed ? "target_unavailable" : s.restrictions.some(Boolean) ? "publication_restricted" : + !proposal && !s.publisher ? "publisher_required" : s.version !== args.expectedContributionVersion ? "contribution_changed" : + !proposal && selecting && (s.identityPlacements.length || s.snapshot.currentImage) ? "primary_occupied" : + !proposal && !selecting && !s.ownSelection ? "selection_changed" : undefined; + if (!code && proposal) { + const now = Date.now(); + const policy = await effectiveContributionPolicy(ctx.db, actor.user._id); + if (await openSubmissionCountForUser(ctx, actor.user._id, now) >= policy.limits.openActor || + await openSubmissionCountForProfile(ctx, s.profile._id, now) >= policy.limits.openTarget) code = "proposal_capacity"; + if (!code) { + try { await assertSubmissionRateLimits(ctx, actor.user._id, s.profile._id, now); } + catch { code = "proposal_rate_limited"; } + } + } + const receipt: CommandReceipt = { operationId: !proposal && selecting ? `contribution-primary:${crypto.randomUUID()}` : crypto.randomUUID(), operationState: code ? "refused" : "committed", resourceId: s.submission._id, ...(code ? { code } : {}) }; + if (!code) { + const now = Date.now(); + if (proposal) receipt.resourceId = await ctx.db.insert("profileMediaSubmissions", { + requestKind: "identity_placement", candidateAssetId: s.asset._id, profileId: s.profile._id, + targetProfileSlug: s.profile.slug, targetProfileDisplayName: s.profile.displayName, + submitterUserId: actor.user._id, submitter: actor.subject, requestedPlacement: s.placement, + targetProfileUpdatedAt: s.profile.updatedAt, targetPlacementAssetId: s.current?.assetId, + targetPlacementVersion: s.snapshot.placementTargetVersion, + sourceUrl: s.asset.sourceUrl, sourceKind: s.submission.sourceKind, sourceDescription: s.submission.sourceDescription, + credit: s.asset.credit ?? s.submission.credit, creditUrl: s.asset.creditUrl, + label: s.asset.label, altText: s.asset.altText, contentSha256: s.asset.contentSha256, + status: "submitted", expiresAt: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS, createdAt: now, updatedAt: now, + }); + else if (selecting) await selectProfileAssetIdentity(ctx.db, { profileId: s.profile._id, assetId: s.asset._id, placement: s.placement, actorUserId: actor.user._id, operationId: receipt.operationId, now }); + else await ctx.db.patch(s.current!._id, { state: "deleted", updatedAt: now }); + await ctx.db.insert("profileAuditEvents", { profileId: s.profile._id, action: proposal ? "profile_media_placement_proposed" : selecting ? "profile_media_primary_selected" : "profile_media_primary_cleared", actor: actor.subject, sourceType: "community", createdAt: now }); + } + await ctx.db.insert("mediaReviewReceipts", { actorUserId: actor.user._id, submissionId: s.submission._id, idempotencyKey: args.idempotencyKey, inputHash, receipt, createdAt: Date.now() }); + return receipt; +} +export function contributionPlacementCommand(ctx: MutationCtx, input: ContributionPlacementCommand, actor: ReviewActor) { return command(ctx, input, actor, false); } +export function proposeContributionPlacement(ctx: MutationCtx, input: ContributionCommandBase, actor: ReviewActor) { return command(ctx, input, actor, true); } diff --git a/convex/_mediaReview.ts b/convex/_mediaReview.ts index 9737a0ac..d7616463 100644 --- a/convex/_mediaReview.ts +++ b/convex/_mediaReview.ts @@ -8,6 +8,7 @@ import { getAccountFeatureAccess } from "./_accountFeatures"; import { userOwnsProfile } from "./_profileOwnership"; import { consumeProfileAssetUploads, + selectProfileAssetIdentity, getPublicProfileMediaKit, PROFILE_MEDIA_SUBMISSION_RETENTION_MS, hasProfileAssetCapacity, @@ -251,7 +252,7 @@ export async function applyReviewDecision( } if ( profile.updatedAt !== args.expectedProfileUpdatedAt || - (submission.requestKind === "identity_placement" && args.decision === "approve" && + (submission.requestKind === "identity_placement" && !submission.candidateAssetId && args.decision === "approve" && profile.updatedAt !== submission.targetProfileUpdatedAt) ) { throw new ConvexError({ @@ -264,7 +265,7 @@ export async function applyReviewDecision( throw new Error("A private review reason is required."); const publicDisposition = sanitizeNote(args.publicDisposition, 240); if (args.decision === "reject") { - await recordPublicationRestriction(ctx, submission, user._id, "rejection"); + if (submission.requestKind !== "identity_placement") await recordPublicationRestriction(ctx, submission, user._id, "rejection"); if (publicDisposition === undefined) { throw new Error("A contributor-visible rejection reason is required."); } @@ -275,7 +276,7 @@ export async function applyReviewDecision( publicDisposition, privateReason, decisionProfileUpdatedAt: profile.updatedAt, - blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS, + ...(submission.requestKind === "identity_placement" ? {} : { blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS }), updatedAt: now, }); await ctx.db.insert("profileAuditEvents", { @@ -288,6 +289,31 @@ export async function applyReviewDecision( return { status: "rejected" as const }; } + if (submission.requestKind === "identity_placement" && submission.candidateAssetId) { + const candidate = await ctx.db.get(submission.candidateAssetId); + const snapshot = await reviewSnapshot(ctx, submission, profile); + if (!candidate || !snapshot.candidate.rendition) throw new Error("The placement candidate is unavailable."); + if (submission.targetPlacementVersion !== snapshot.placementTargetVersion) + throw new Error("The profile media placement changed. Rebase before deciding."); + if ((snapshot.currentPlacement?.assetId ?? undefined) !== submission.targetPlacementAssetId) + throw new Error("The profile media placement changed. Refresh before deciding."); + if (await privateReplacementRequiresElevatedAccess(ctx, profile._id, snapshot.currentPlacement?.assetId, ownsProfile || access.superAdmin)) + throw new Error("Private replacement requires profile owner or admin access."); + const operationId = commandEvidence?.operationId ?? crypto.randomUUID(); + if (submission.requestedPlacement !== "profile_image" && submission.requestedPlacement !== "primary_logo") + throw new Error("Identity placement is invalid."); + await selectProfileAssetIdentity(ctx.db, { profileId: profile._id, assetId: candidate._id, + placement: submission.requestedPlacement, actorUserId: user._id, operationId, now }); + if (!commandEvidence) await ctx.db.insert("mediaReviewReceipts", { actorUserId: user._id, + idempotencyKey: `legacy:${operationId}`, inputHash: await hash(args), submissionId: submission._id, + receipt: { operationId, operationState: "committed", resourceId: submission._id }, createdAt: now }); + await ctx.db.patch(submission._id, { status: "approved", approvedAssetId: candidate._id, + reviewer: subject, reviewedAt: now, privateReason, publicDisposition, + decisionProfileUpdatedAt: profile.updatedAt, updatedAt: now }); + await ctx.db.insert("profileAuditEvents", { profileId: profile._id, action: "profile_media_placement_approved", + actor: subject, sourceType: ownsProfile ? "owner" : "moderator", createdAt: now }); + return { status: "approved" as const, assetId: candidate._id }; + } if (submission.uploadIntentId === undefined) { throw new Error("The submitted media upload is missing."); } @@ -486,7 +512,13 @@ export async function reviewSnapshot( submission.uploadIntentId === undefined ? null : await ctx.db.get(submission.uploadIntentId); - const candidateReady = + const candidateAsset = submission.requestKind === "identity_placement" && submission.candidateAssetId + ? await ctx.db.get(submission.candidateAssetId) : null; + const existingCandidateReady = candidateAsset !== null && candidateAsset.profileId === profile._id && + candidateAsset.state === "active" && candidateAsset.visibility === "public" && + candidateAsset.retiredAt === undefined && candidateAsset.moderatorSuppressedAt === undefined && + candidateAsset.contentSha256 === submission.contentSha256; + const candidateReady = existingCandidateReady || ( intent !== null && intent.purpose === "community_proposal" && intent.targetSubmissionId === submission._id && @@ -494,7 +526,7 @@ export async function reviewSnapshot( (intent.state === "uploaded" || intent.state === "consumed") && submission.blobDeletedAt === undefined && submission.blobCleanupToken === undefined && - intent.contentSha256 === submission.contentSha256; + intent.contentSha256 === submission.contentSha256); // The hash binds the stored rendition and provenance, current target and placement, // and explicit rebase revision. Advisory startReview is deliberately excluded. const artworkEvidence = await Promise.all( @@ -506,6 +538,17 @@ export async function reviewSnapshot( ), ); const kitAsset = submission.requestKind !== "identity_placement"; + const placementTargetVersion = await hash({ + profileId: profile._id, profileType: profile.profileType, slug: profile.slug, displayName: profile.displayName, + claimState: profile.claimState, publicationState: profile.publicationState, publicSurfacingState: profile.publicSurfacingState, + fieldVisibility: profile.fieldVisibility, identityDestinations: (profile.outboundLinks ?? []).flatMap(link => { + const destination = parseProfileLinkDestination(link); return destination ? [destination.key] : []; + }).sort(), + placement, currentImage, currentAutomaticImageUrl, + currentAsset: currentAsset && { id: currentAsset._id, credit: currentAsset.credit, sourceUrl: currentAsset.sourceUrl, + state: currentAsset.state, visibility: currentAsset.visibility, retiredAt: currentAsset.retiredAt, moderatorSuppressedAt: currentAsset.moderatorSuppressedAt }, + identityPlacements: authoredPlacements.filter(row => row.placement === "profile_image" || row.placement === "primary_logo"), + }); const restrictions = await Promise.all([ ctx.db.query("mediaPublicationRestrictions").withIndex("by_profileId_kind", q => q.eq("profileId", profile._id).eq("kind", "identity")).collect(), ctx.db.query("mediaPublicationRestrictions").withIndex("by_profileId_kind", q => q.eq("profileId", profile._id).eq("kind", "dispute")).collect(), @@ -529,15 +572,17 @@ export async function reviewSnapshot( label: submission.label, altText: submission.altText, expiresAt: submission.expiresAt, - targetProfileUpdatedAt: kitAsset ? undefined : submission.targetProfileUpdatedAt, + targetProfileUpdatedAt: kitAsset || submission.candidateAssetId ? undefined : submission.targetProfileUpdatedAt, targetPlacementAssetId: kitAsset ? undefined : submission.targetPlacementAssetId, requestedPlacement: submission.requestedPlacement, requestKind: submission.requestKind, candidateAssetId: submission.candidateAssetId, + targetPlacementVersion: submission.targetPlacementVersion, }, intent, + candidateAsset, restrictions, - profile: kitAsset ? { + profile: kitAsset || submission.candidateAssetId ? { id: profile._id, profileType: profile.profileType, slug: profile.slug, displayName: profile.displayName, claimState: profile.claimState, publicationState: profile.publicationState, publicSurfacingState: profile.publicSurfacingState, @@ -546,11 +591,12 @@ export async function reviewSnapshot( const destination = parseProfileLinkDestination(link); return destination ? [destination.key] : []; }).sort(), + ...(!kitAsset ? { avatarImageUrl: profile.avatarImageUrl } : {}), } : profile, placement: kitAsset ? undefined : placement, currentAsset: kitAsset ? undefined : currentAsset, currentAutomaticImageUrl: kitAsset ? undefined : currentAutomaticImageUrl, - authoredPlacements: kitAsset ? undefined : authoredPlacements, + authoredPlacements: kitAsset ? undefined : authoredPlacements.filter(row => row.placement === "profile_image" || row.placement === "primary_logo"), artworkEvidence: kitAsset ? undefined : artworkEvidence.map((row) => row && ({ key: row.key, kind: row.kind, locator: row.locator, provider: row.provider, status: row.status, entityId: row.entityId, artworkSourceUrl: row.artworkSourceUrl, @@ -561,6 +607,7 @@ export async function reviewSnapshot( revision: submission.reviewRevision ?? 0, }); return { + placementTargetVersion, reviewVersion, currentImage, currentPlacement: @@ -648,6 +695,7 @@ export async function decideReviewCommand( else if ( args.decision === "approve" && submission.requestKind === "identity_placement" && + !submission.candidateAssetId && profile.updatedAt !== submission.targetProfileUpdatedAt ) code = "target_changed"; @@ -662,6 +710,8 @@ export async function decideReviewCommand( code = "public_reason_required"; else if (args.decision === "approve" && snapshot.candidate.rendition === null) code = "candidate_unavailable"; + else if (args.decision === "approve" && submission.candidateAssetId && + submission.targetPlacementVersion !== snapshot.placementTargetVersion) code = "placement_changed"; if (code === undefined && args.decision === "approve" && submission.requestKind === "identity_placement" && await privateReplacementRequiresElevatedAccess( @@ -670,7 +720,7 @@ export async function decideReviewCommand( )) code = "private_replacement_requires_owner_or_admin"; if (code === undefined && args.decision === "approve") { if (!submission.credit.trim()) code = "credit_required"; - else if (submission.contentSha256 !== undefined) { + else if (submission.contentSha256 !== undefined && submission.candidateAssetId === undefined) { const duplicate = await ctx.db .query("profileAssets") .withIndex("by_profileId_contentSha256_state", (q) => @@ -683,7 +733,7 @@ export async function decideReviewCommand( if (duplicate !== null) code = "already_published"; } } - if (code === undefined && args.decision === "approve") { + if (code === undefined && args.decision === "approve" && submission.candidateAssetId === undefined) { if (!(await hasProfileAssetCapacity(ctx.db, profile._id, 1))) code = "capacity_exceeded"; } @@ -851,6 +901,7 @@ export async function rebaseReviewCommand( targetProfileDisplayName: profile.displayName, targetProfileUpdatedAt: profile.updatedAt, targetPlacementAssetId: snapshot.currentPlacement?.assetId, + ...(submission.candidateAssetId ? { targetPlacementVersion: snapshot.placementTargetVersion } : {}), reviewRevision: (submission.reviewRevision ?? 0) + 1, updatedAt: Date.now(), }); diff --git a/convex/_profileAssets.ts b/convex/_profileAssets.ts index 23a201f9..d628690f 100644 --- a/convex/_profileAssets.ts +++ b/convex/_profileAssets.ts @@ -82,6 +82,31 @@ export type PublicProfileMediaKit = { export type ProfileAssetDisplayPreference = Doc<"profileAssetDisplayPreferences">; +/** Change the identity slot, retaining assets that still have gallery or other placements. */ +export async function selectProfileAssetIdentity(db: DatabaseWriter, input: { + profileId: Id<"profiles">; assetId: Id<"profileAssets">; + placement: "profile_image" | "primary_logo"; actorUserId?: Id<"users">; + operationId?: string; now: number; +}) { + const current = await db.query("profileAssetPlacements").withIndex("by_profileId_placement_state_position", q => + q.eq("profileId", input.profileId).eq("placement", input.placement).eq("state", "active")).collect(); + await Promise.all(current.map(row => db.patch(row._id, { state: "deleted", updatedAt: input.now }))); + await db.insert("profileAssetPlacements", { + profileId: input.profileId, assetId: input.assetId, placement: input.placement, position: 0, + state: "active", updatedAt: input.now, selectionActorUserId: input.actorUserId, + selectionOperationId: input.operationId ?? crypto.randomUUID(), + }); + for (const assetId of new Set(current.map(row => row.assetId))) { + if (assetId === input.assetId) continue; + const remaining = await db.query("profileAssetPlacements").withIndex("by_assetId_state_placement", q => q.eq("assetId", assetId).eq("state", "active")).first(); + if (remaining) continue; + const asset = await db.get(assetId); + if (asset && asset.retiredAt === undefined) await db.patch(assetId, { + state: "deleted", deletedAt: asset.deletedAt ?? input.now, retiredAt: input.now, updatedAt: input.now, + }); + } +} + function bytesToHex(bytes: ArrayBuffer): string { return [...new Uint8Array(bytes)] .map((byte) => byte.toString(16).padStart(2, "0")) @@ -149,6 +174,8 @@ export async function getProfileMediaVersion( assetId: String(placement.assetId), placement: placement.placement, position: placement.position, + selectionActorUserId: placement.selectionActorUserId, + selectionOperationId: placement.selectionOperationId, })); const digest = await crypto.subtle.digest( "SHA-256", @@ -756,6 +783,7 @@ export async function consumeProfileAssetUploads( uploads: ProfileAssetUploadInput[]; source: Doc<"profileAssets">["source"]; approvedSubmissionId?: Id<"profileMediaSubmissions">; + selectionActorUserId?: Id<"users">; bridgeAuthorized?: boolean; now: number; }, @@ -832,6 +860,15 @@ export async function consumeProfileAssetUploads( }); for (const placement of upload.placements) { + if (placement === "profile_image" || placement === "primary_logo") { + if (seenPlacementKeys.has(placement)) continue; + seenPlacementKeys.add(placement); + const actor = input.selectionActorUserId ?? (input.requestedBy.issuer === "vrdex:api" + ? db.normalizeId("users", input.requestedBy.subject) + : (await db.query("users").withIndex("clerkUserId", q => q.eq("clerkUserId", input.requestedBy.subject)).unique())?._id); + await selectProfileAssetIdentity(db, { profileId: input.profileId, assetId, placement, actorUserId: actor ?? undefined, now: input.now }); + continue; + } const orderedMultiPlacement = placement === "additional_logo" || placement === "gallery"; const key = orderedMultiPlacement ? `${placement}:${assetId}` : placement; if (seenPlacementKeys.has(key)) { diff --git a/convex/_trustedPublication.ts b/convex/_trustedPublication.ts index c34546ce..52c8849b 100644 --- a/convex/_trustedPublication.ts +++ b/convex/_trustedPublication.ts @@ -156,6 +156,7 @@ async function publicationOrLegacyDeclaration( ? "candidate_unavailable" : undefined; if (declaration) code = "declaration_retired"; + if (!declaration && submission.requestKind === "identity_placement") code = "placement_requires_review"; if (!declaration && !code) { const [ identityRestriction, @@ -201,6 +202,7 @@ async function publicationOrLegacyDeclaration( .eq("contentSha256", submission.contentSha256) .eq("status", "rejected"), ) + .filter(q => q.neq(q.field("requestKind"), "identity_placement")) .first() : null, submission.contentSha256 diff --git a/convex/profileAssets.ts b/convex/profileAssets.ts index b21829b8..d42ecc0f 100644 --- a/convex/profileAssets.ts +++ b/convex/profileAssets.ts @@ -26,6 +26,7 @@ import { canReadProfile } from "./_profilePermissions"; import { isProfileFieldVisible } from "./_profileFieldVisibility"; import { PROFILE_ASSET_MAX_ACTIVE_COUNT, + selectProfileAssetIdentity, PROFILE_ASSET_UPLOAD_PROCESSING_MAX_ATTEMPTS, PROFILE_ASSET_UPLOAD_PROCESSING_LEASE_MS, assertProfileMediaVersion, @@ -1320,6 +1321,7 @@ async function replaceOwnedAssetPlacements( asset: Doc<"profileAssets">, placements: ProfileAssetPlacement[], now: number, + actorUserId: Id<"users">, ) { const uniquePlacements = [...new Set(placements)]; @@ -1342,6 +1344,8 @@ async function replaceOwnedAssetPlacements( .filter((query) => query.eq(query.field("state"), "active")) .collect(); const desired = new Set(uniquePlacements); + if (asset.sourceSubmissionId && uniquePlacements.some(p => p === "profile_image" || p === "primary_logo") && + current.some(p => p.placement === "gallery")) desired.add("gallery"); await Promise.all( current @@ -1350,6 +1354,10 @@ async function replaceOwnedAssetPlacements( ); for (const placement of uniquePlacements) { + if (placement === "profile_image" || placement === "primary_logo") { + await selectProfileAssetIdentity(db, { profileId, assetId: asset._id, placement, actorUserId, now }); + continue; + } if (current.some((item) => item.placement === placement)) { continue; } @@ -1549,6 +1557,7 @@ export const manageOwnedMediaForMcpActor = internalMutation({ currentAsset, args.asset.placements, now, + args.ownerUserId, ); } diff --git a/convex/profileMediaSubmissions.ts b/convex/profileMediaSubmissions.ts index 8d9496ab..bb7b6538 100644 --- a/convex/profileMediaSubmissions.ts +++ b/convex/profileMediaSubmissions.ts @@ -1,4 +1,5 @@ import { assertLiveContributionToken } from "./_contributionAuth"; +import { publishedContributionDetail, contributionPlacementCommand, proposeContributionPlacement } from "./_mediaContributionCommands"; import { effectiveContributionPolicy, contributionChargeRefusal, registerLegacyContribution, reservationBytes } from "./_contributionCapacity"; import { requirePublisher, publicationCommand, legacyDeclarationCommand, recordPublicationRestriction } from "./_trustedPublication"; import { readScopedPagination, writeScopedPagination } from "./_reviewCursor"; @@ -42,6 +43,36 @@ import { requireMcpAttributionText, requireSha256Hex } from "./_mcpWriteReceipts const OPEN_SUBMISSION_STATUSES = ["upload_pending", "submitted", "under_review"] as const; const submissionId = v.id("profileMediaSubmissions"); +const contributionCommandArgs = { + submissionId, expectedContributionVersion: v.string(), idempotencyKey: v.string(), +}; +const placementCommandArgs = { ...contributionCommandArgs, + action: v.union(v.literal("select_primary"), v.literal("clear_primary")), +}; +export const contributionDetail = query({ args: { submissionId }, handler: async (ctx, args) => + publishedContributionDetail(ctx, args.submissionId, await browserReviewActor(ctx)) }); +export const contributionDetailForMcpActor = internalQuery({ + args: { submissionId, actorUserId: v.id("users"), ...reviewActorAttestationArgs }, + handler: async (ctx, args) => publishedContributionDetail(ctx, args.submissionId, + await trustedReviewActor(ctx, args.actorUserId, args, "assets:contribute", false)), +}); +export const placeContribution = mutation({ args: placementCommandArgs, handler: async (ctx, args) => + contributionPlacementCommand(ctx, args, await browserReviewActor(ctx)) }); +export const placeContributionForMcpActor = internalMutation({ + args: { ...placementCommandArgs, actorUserId: v.id("users"), ...reviewActorAttestationArgs }, + handler: async (ctx, args) => contributionPlacementCommand(ctx, + { submissionId: args.submissionId, expectedContributionVersion: args.expectedContributionVersion, + idempotencyKey: args.idempotencyKey, action: args.action }, + await trustedReviewActor(ctx, args.actorUserId, args, "assets:publish")), +}); +export const proposePlacement = mutation({ args: contributionCommandArgs, handler: async (ctx, args) => + proposeContributionPlacement(ctx, args, await browserReviewActor(ctx)) }); +export const proposePlacementForMcpActor = internalMutation({ + args: { ...contributionCommandArgs, actorUserId: v.id("users"), ...reviewActorAttestationArgs }, + handler: async (ctx, args) => proposeContributionPlacement(ctx, + { submissionId: args.submissionId, expectedContributionVersion: args.expectedContributionVersion, idempotencyKey: args.idempotencyKey }, + await trustedReviewActor(ctx, args.actorUserId, args, "assets:contribute")), +}); const requestedPlacement = v.union(v.literal("profile_image"), v.literal("primary_logo"), v.literal("gallery")); const reviewQueueStatus = v.union( v.literal("submitted"), @@ -1158,7 +1189,7 @@ async function ownSubmission( .withIndex("by_submissionId", (q) => q.eq("submissionId", submission._id)) .unique(); const revision = attempt ? await ctx.db.get(attempt.revisionId) : null; - const snapshot = await reviewSnapshot(ctx, submission, profile); + const { placementTargetVersion: _placementTargetVersion, ...snapshot } = await reviewSnapshot(ctx, submission, profile); const asset = submission.approvedAssetId ? await ctx.db.get(submission.approvedAssetId) : null; @@ -1648,7 +1679,7 @@ async function withdrawSubmission( const now = Date.now(); await ctx.db.patch(submission._id, { status: "withdrawn", - blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS, + ...(submission.requestKind === "identity_placement" ? {} : { blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS }), updatedAt: now, }); await ctx.db.insert("profileAuditEvents", { @@ -2091,7 +2122,7 @@ async function authorizedReviewDetail( profile, access.access.superAdmin, ); - const snapshot = await reviewSnapshot(ctx, submission, profile); + const { placementTargetVersion: _placementTargetVersion, ...snapshot } = await reviewSnapshot(ctx, submission, profile); return { ...projection, ...snapshot, @@ -2129,6 +2160,14 @@ async function authorizedCandidateStorage( const detail = await authorizedReviewDetail(ctx, id, actor, publisher); if (detail?.candidate.rendition == null) return null; const submission = await ctx.db.get(id); + if (submission?.requestKind === "identity_placement" && submission.candidateAssetId) { + const asset = await ctx.db.get(submission.candidateAssetId); + return asset ? { + storageKey: asset.downloadStorageKey ?? asset.storageKey, + mimeType: asset.downloadMimeType ?? asset.mimeType, + originalFileName: asset.originalFileName, profileDisplayName: detail.profileDisplayName, + } : null; + } const intent = submission?.uploadIntentId === undefined ? null diff --git a/convex/schema.ts b/convex/schema.ts index 162f8de6..6567b7c3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -950,6 +950,7 @@ export default defineSchema({ status: profileMediaSubmissionStatus, targetProfileUpdatedAt: v.number(), targetPlacementAssetId: v.optional(v.id("profileAssets")), + targetPlacementVersion: v.optional(v.string()), decisionProfileUpdatedAt: v.optional(v.number()), reviewer: v.optional(authSubject), reviewedAt: v.optional(v.number()), @@ -1020,6 +1021,8 @@ export default defineSchema({ .index("by_profileId_contentSha256_state", ["profileId", "contentSha256", "state"]) .index("by_profileId_state_visibility", ["profileId", "state", "visibility"]), profileAssetPlacements: defineTable({ + selectionActorUserId: v.optional(v.id("users")), + selectionOperationId: v.optional(v.string()), profileId: v.id("profiles"), assetId: v.id("profileAssets"), placement: profileAssetPlacement, diff --git a/docs/engineering/media-review-commands.md b/docs/engineering/media-review-commands.md index 7f093618..91c22172 100644 --- a/docs/engineering/media-review-commands.md +++ b/docs/engineering/media-review-commands.md @@ -59,6 +59,10 @@ state, and the submission's optional `reviewRevision`. Missing revisions mean zero for existing rows. Advisory start-review updates do not invalidate it. Kit publication excludes unrelated biography timestamps and existing picture artwork. Placement inspection includes the current placement and asset. +Existing-asset placement requests store a narrow original-target snapshot that +includes the exact selection operation. An intervening selection, including +the same asset, requires a rebase. Unrelated biography edits do not invalidate +these requests. Their authorized preview reads the existing stored asset. Meaningful evidence changes require fresh inspection. Placement changes also require an explicit rebase against the original selection snapshot. @@ -77,6 +81,25 @@ may be retried with the same key. Legacy `decide` retains its argument/result shape and calls the shared transition. The receipt path is the required interface for new browser/MCP clients. +## Published contribution placement + +`contributionDetail` and its internal MCP actor counterpart resolve the immutable +source submission and active public asset. `placeContribution` accepts the source +submission ID, expected contribution version, actor-scoped idempotency key, and +`select_primary` or `clear_primary`. Verified trusted publishers may select their +own asset on a public, published, unclaimed profile only when no managed, legacy, +or automatic identity image occupies the slot. Undo requires current publisher +authority and the exact unchanged publisher selection. Owner and reviewer actions +stamp a fresh selection operation, including same-asset reselection. + +`proposePlacement` accepts the same base command without an action. It references +the existing asset in an `identity_placement` review submission and uses current +proposal count and rate limits. Approval requires a distinct authorized reviewer +and changes only the identity placement. Both gallery entries remain published; +no upload, blob, active asset, or published-byte charge is created. Rejection and +withdrawal record the placement-request disposition without content rejection or +proposal-blob cleanup. Explicit moderator suppression remains separate. + ## Delegation and rollout Review reads use `assets:review:read` plus `mcp:read`; decisions use diff --git a/packages/api-contracts/src/media-review.ts b/packages/api-contracts/src/media-review.ts index 106e0dd3..b4edc030 100644 --- a/packages/api-contracts/src/media-review.ts +++ b/packages/api-contracts/src/media-review.ts @@ -1,6 +1,29 @@ import { z } from "zod"; const boundedId = z.string().min(1).max(200); +export const contributionCommandBaseSchema = z.strictObject({ + submissionId: boundedId, + expectedContributionVersion: z.string().min(1).max(128), + idempotencyKey: z.string().min(1).max(128), +}); +export type ContributionCommandBase = z.infer; +export const contributionPlacementCommandSchema = z.strictObject({ + ...contributionCommandBaseSchema.shape, + action: z.enum(["select_primary", "clear_primary"]), +}); +export type ContributionPlacementCommand = z.infer; +export const publishedContributionDetailSchema = z.strictObject({ + submissionId: boundedId, assetId: boundedId, profileId: boundedId, + profileSlug: z.string().max(200), contributionVersion: z.string().min(1).max(128), + metadata: z.strictObject({ + label: z.string().max(80), altText: z.string().max(180).optional(), + credit: z.string().max(120), creditUrl: z.string().max(4096).optional(), + sourceUrl: z.string().max(4096).optional(), sourceDescription: z.string().max(1000).optional(), + }), + canSelectPrimary: z.boolean(), canClearPrimary: z.boolean(), + canEditMetadata: z.boolean(), canRemove: z.boolean(), +}); +export type PublishedContributionDetail = z.infer; export const commandReceiptSchema = z.strictObject({ operationId: boundedId, operationState: z.enum(["committed", "refused", "in_progress"]), diff --git a/packages/api-contracts/tests/media-review.test.ts b/packages/api-contracts/tests/media-review.test.ts index fe7cc1d7..d831aadf 100644 --- a/packages/api-contracts/tests/media-review.test.ts +++ b/packages/api-contracts/tests/media-review.test.ts @@ -3,8 +3,28 @@ import { it } from "node:test"; import { reviewDecisionSchema, reviewPageRequestSchema, + contributionCommandBaseSchema, + contributionPlacementCommandSchema, + publishedContributionDetailSchema, } from "../src/media-review"; import { apiScopes } from "../src/auth"; +it("bounds contribution commands and refuses caller-selected capabilities", () => { + const base = { submissionId: "submission", expectedContributionVersion: "version", idempotencyKey: "key" }; + assert.equal(contributionCommandBaseSchema.safeParse(base).success, true); + assert.equal(contributionPlacementCommandSchema.safeParse({ ...base, action: "select_primary" }).success, true); + for (const invalid of [ + { ...base, action: "clear_primary", canClearPrimary: true }, + { ...base, action: "replace_primary" }, + { ...base, action: "select_primary", expectedContributionVersion: "v".repeat(129) }, + { ...base, action: "select_primary", idempotencyKey: "k".repeat(129) }, + { ...base, action: "select_primary", submissionId: "s".repeat(201) }, + ]) assert.equal(contributionPlacementCommandSchema.safeParse(invalid).success, false); + assert.equal(publishedContributionDetailSchema.safeParse({ + submissionId: "submission", assetId: "asset", profileId: "profile", profileSlug: "dj", + contributionVersion: "version", metadata: { label: "Image", credit: "Artist" }, + canSelectPrimary: true, canClearPrimary: false, canEditMetadata: true, canRemove: true, + }).success, true); +}); import { dynamicMcpClientScopes, dynamicMcpResourceWriteScopes, diff --git a/tests/backend/media-contribution-commands.test.ts b/tests/backend/media-contribution-commands.test.ts new file mode 100644 index 00000000..f540e61d --- /dev/null +++ b/tests/backend/media-contribution-commands.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { convexTest } from "convex-test"; +import { api, internal } from "../../convex/_generated/api"; +import { modules, schema, seed, createAndUpload } from "./_mediaReviewFixture"; +import { getProfileMediaVersion, selectProfileAssetIdentity } from "../../convex/_profileAssets"; + +process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED = "true"; +process.env.VRDEX_PROFILE_MEDIA_DIRECT_UPLOAD_ENABLED = "true"; +process.env.VRDEX_PROFILE_MEDIA_KIT_ENABLED = "true"; + +async function fixture(profileType: "person" | "community" = "person") { + const t = convexTest({ schema, modules }); + const s = await seed(t, profileType); + const { intent } = await createAndUpload(t, s); + const grant = await t.run(ctx => ctx.db.insert("accountFeatureGrants", { + userId: s.contributorUserId, feature: "trusted_publisher", state: "active", + grantedBy: { tokenIdentifier: "test:operator", issuer: "test", subject: "operator" }, + grantedAt: Date.now(), updatedAt: Date.now(), + })); + const actor = t.withIdentity(s.contributorIdentity); + const pending = await actor.query(api.profileMediaSubmissions.publisherDetail, { submissionId: intent.submissionId }); + assert.ok(pending); + assert.equal((await actor.mutation(api.profileMediaSubmissions.publish, { + submissionId: intent.submissionId, expectedReviewVersion: pending.reviewVersion, idempotencyKey: "publish", + })).operationState, "committed"); + await t.run(ctx => ctx.db.patch(intent.submissionId, { createdAt: Date.now() - 60_000 })); + const detail = () => actor.query(api.profileMediaSubmissions.contributionDetail, { submissionId: intent.submissionId }); + const place = async (action: "select_primary" | "clear_primary", idempotencyKey = crypto.randomUUID()) => { + const d = await detail(); + return actor.mutation(api.profileMediaSubmissions.placeContribution, { + submissionId: intent.submissionId, expectedContributionVersion: d.contributionVersion, action, idempotencyKey, + }); + }; + const placements = () => t.run(async ctx => (await ctx.db.query("profileAssetPlacements").collect()).filter(p => p.state === "active").map(p => p.placement).sort()); + return { t, s, intent, grant, actor, detail, place, placements }; +} + +for (const profileType of ["person", "community"] as const) { + it(`selects and clears its own ${profileType} primary while keeping gallery and bytes`, async () => { + const f = await fixture(profileType); + assert.equal((await f.detail()).canSelectPrimary, true); + assert.equal((await f.place("select_primary")).operationState, "committed"); + assert.deepEqual(await f.placements(), ["gallery", profileType === "person" ? "profile_image" : "primary_logo"].sort()); + assert.equal((await f.detail()).canClearPrimary, true); + assert.equal((await f.place("clear_primary")).operationState, "committed"); + assert.deepEqual(await f.placements(), ["gallery"]); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").first()))?.state, "active"); + }); +} + +it("replays a command once and refuses changed-key input and stale versions", async () => { + const f = await fixture(); + const d = await f.detail(); + const input = { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, action: "select_primary" as const, idempotencyKey: "selection" }; + const first = await f.actor.mutation(api.profileMediaSubmissions.placeContribution, input); + assert.deepEqual(await f.actor.mutation(api.profileMediaSubmissions.placeContribution, input), first); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.placeContribution, { ...input, action: "clear_primary" })).code, "idempotency_conflict"); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.placeContribution, { ...input, action: "clear_primary", idempotencyKey: "stale" })).code, "contribution_changed"); +}); + +for (const condition of ["legacy", "revoked", "claimed", "private", "other_contributor"] as const) { + it(`refuses selection after ${condition}`, async () => { + const f = await fixture(); + await f.t.run(async ctx => { + if (condition === "legacy") await ctx.db.patch(f.s.profileId, { avatarImageUrl: "https://example.test/current.webp" }); + if (condition === "revoked") await ctx.db.patch(f.grant, { state: "revoked" }); + if (condition === "claimed") await ctx.db.patch(f.s.profileId, { claimState: "claimed_verified" }); + if (condition === "private") await ctx.db.patch(f.s.profileId, { publicSurfacingState: "opted_out" }); + if (condition === "other_contributor") await ctx.db.patch(f.intent.submissionId, { submitterUserId: f.s.moderatorUserId }); + }); + if (condition === "other_contributor") await assert.rejects(f.detail(), /unavailable/i); + else { + assert.equal((await f.detail()).canSelectPrimary, false); + assert.equal((await f.place("select_primary")).operationState, "refused"); + } + assert.deepEqual(await f.placements(), ["gallery"]); + }); +} + +it("same-asset reselection by another authorized actor invalidates publisher undo", async () => { + const f = await fixture(); + await f.place("select_primary"); + await f.t.run(async ctx => { + const p = await ctx.db.query("profileAssetPlacements").filter(q => q.eq(q.field("placement"), "profile_image")).first(); + assert.ok(p); + await ctx.db.patch(p._id, { selectionActorUserId: f.s.moderatorUserId, selectionOperationId: "admin-reselection" }); + }); + assert.equal((await f.detail()).canClearPrimary, false); + assert.equal((await f.place("clear_primary")).operationState, "refused"); +}); + +it("owner same-asset selection stamps new provenance and retains the contribution gallery", async () => { + const f = await fixture(); + await f.place("select_primary"); + const d = await f.detail(); + const before = await f.t.run(async ctx => (await ctx.db.query("profileAssetPlacements").collect()).find(p => p.placement === "profile_image" && p.state === "active")); + await f.t.run(async ctx => { + await ctx.db.patch(f.s.profileId, { claimState: "claimed_verified" }); + await ctx.db.insert("profileOwners", { profileId: f.s.profileId, userId: f.s.moderatorUserId, roleKey: "owner", state: "active", grantedAt: Date.now(), updatedAt: Date.now() }); + }); + const version = await f.t.run(ctx => getProfileMediaVersion(ctx.db, f.s.profileId)); + await f.t.mutation(internal.profileAssets.manageOwnedMediaForMcpActor, { + ownerUserId: f.s.moderatorUserId, oauthClientId: "client", oauthTokenId: "token", requestId: "reselect", + slug: "community-dj", expectedMediaVersion: version, asset: { assetId: d.assetId, placements: ["profile_image"] }, + }); + assert.deepEqual(await f.placements(), ["gallery", "profile_image"]); + const after = await f.t.run(async ctx => (await ctx.db.query("profileAssetPlacements").collect()).find(p => p.placement === "profile_image" && p.state === "active")); + assert.equal(after?.selectionActorUserId, f.s.moderatorUserId); + assert.notEqual(after?.selectionOperationId, before?.selectionOperationId); + assert.notEqual(await f.t.run(ctx => getProfileMediaVersion(ctx.db, f.s.profileId)), version); + await f.t.run(ctx => ctx.db.patch(f.s.profileId, { claimState: "unclaimed" })); + assert.equal((await f.detail()).canClearPrimary, false); +}); + +it("ignores biography edits but binds review to the exact original primary selection", async () => { + const f = await fixture(); + const d = await f.detail(); + await f.t.run(ctx => ctx.db.patch(f.s.profileId, { headline: "Changed biography", updatedAt: Date.now() })); + assert.equal((await f.detail()).contributionVersion, d.contributionVersion); + const p = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "proposal" }); + assert.equal(p.operationState, "committed"); + await f.place("select_primary"); + const reviewer = f.t.withIdentity(f.s.moderatorIdentity); + const review = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: p.resourceId! }); + assert.ok(review); + const result = await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: p.resourceId!, expectedReviewVersion: review.reviewVersion, decision: "approve", privateReason: "Placement checked", idempotencyKey: "changed-placement" }); + assert.equal(result.code, "placement_changed"); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.rebase, { submissionId: p.resourceId!, expectedReviewVersion: review.reviewVersion, idempotencyKey: "rebase-placement" })).operationState, "committed"); + const rebased = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: p.resourceId! }); + assert.ok(rebased); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: p.resourceId!, expectedReviewVersion: rebased.reviewVersion, decision: "approve", privateReason: "Current placement checked", idempotencyKey: "approve-rebased" })).operationState, "committed"); + assert.equal((await f.detail()).canClearPrimary, false); +}); + +it("legacy placements never authorize undo and publisher revocation refuses historical command replay", async () => { + const f = await fixture(); + const d = await f.detail(); + const input = { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, action: "select_primary" as const, idempotencyKey: "original-selection" }; + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.placeContribution, input)).operationState, "committed"); + await f.t.run(async ctx => { + const p = await ctx.db.query("profileAssetPlacements").filter(q => q.eq(q.field("placement"), "profile_image")).first(); + assert.ok(p); + await ctx.db.patch(p._id, { selectionActorUserId: undefined, selectionOperationId: undefined }); + }); + assert.equal((await f.detail()).canClearPrimary, false); + assert.equal((await f.place("clear_primary")).operationState, "refused"); + await f.t.run(ctx => ctx.db.patch(f.grant, { state: "revoked" })); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.placeContribution, input)).code, "authority_changed"); +}); + +it("reviews existing-asset placement without a new upload or published charge", async () => { + const f = await fixture(); + const d = await f.detail(); + const proposal = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, { + submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "proposal", + }); + assert.equal(proposal.operationState, "committed"); + const publisherPreview = await f.actor.query(api.profileMediaSubmissions.publisherDetail, { submissionId: proposal.resourceId! }); + assert.ok(publisherPreview); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.publish, { submissionId: proposal.resourceId!, expectedReviewVersion: publisherPreview.reviewVersion, idempotencyKey: "publish-placement" })).code, "placement_requires_review"); + const reviewer = f.t.withIdentity(f.s.moderatorIdentity); + const review = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: proposal.resourceId! }); + assert.ok(review?.candidate.rendition); + const stored = await reviewer.query(api.profileMediaSubmissions.getCandidateForStorage, { submissionId: proposal.resourceId! }); + assert.equal(stored?.storageKey, (await f.t.run(ctx => ctx.db.query("profileAssets").first()))?.storageKey); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { + submissionId: proposal.resourceId!, expectedReviewVersion: review.reviewVersion, decision: "approve", privateReason: "Correct identity", idempotencyKey: "approve-placement", + })).operationState, "committed"); + assert.deepEqual(await f.placements(), ["gallery", "profile_image"]); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssetUploadIntents").collect())).length, 1); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").collect())).length, 1); +}); + +for (const disposition of ["reject", "withdraw"] as const) { + it(`${disposition} leaves the published asset and its bytes alone`, async () => { + const f = await fixture(); + const d = await f.detail(); + const p = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "proposal" }); + assert.equal(p.operationState, "committed"); + const id = p.resourceId!; + if (disposition === "withdraw") await f.actor.mutation(api.profileMediaSubmissions.withdraw, { submissionId: id }); + else { + const reviewer = f.t.withIdentity(f.s.moderatorIdentity); + const review = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: id }); + assert.ok(review); + await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: id, expectedReviewVersion: review.reviewVersion, decision: "reject", privateReason: "Placement declined", publicReason: "Declined", idempotencyKey: "reject" }); + } + assert.equal((await f.t.run(ctx => ctx.db.get(id)))?.blobDeleteAfter, undefined); + assert.equal((await f.t.run(ctx => ctx.db.query("mediaPublicationRestrictions").collect())).length, 0); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").first()))?.state, "active"); + assert.deepEqual(await f.placements(), ["gallery"]); + }); +} + +it("requires a distinct reviewer and replaces identity at full capacity without moving either gallery item", async () => { + const f = await fixture(); + const d = await f.detail(); + const beforeBytes = await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()); + const oldAssetId = await f.t.run(async ctx => { + const asset = await ctx.db.get(d.assetId); + assert.ok(asset); + const { _id, _creationTime, sourceSubmissionId, ...fields } = asset; + let old; + for (let i = 0; i < 11; i++) { + const id = await ctx.db.insert("profileAssets", { ...fields, storageKey: `stored/${i}.webp`, contentSha256: `other-${i}` }); + await ctx.db.insert("profileAssetPlacements", { profileId: f.s.profileId, assetId: id, placement: "gallery", position: i + 1, state: "active", updatedAt: Date.now() }); + if (i === 0) old = id; + } + assert.ok(old); + await selectProfileAssetIdentity(ctx.db, { profileId: f.s.profileId, assetId: old, placement: "profile_image", actorUserId: f.s.moderatorUserId, now: Date.now() }); + await ctx.db.insert("accountFeatureGrants", { userId: f.s.contributorUserId, feature: "super_admin", state: "active", grantedBy: { tokenIdentifier: "test:operator", issuer: "test", subject: "operator" }, grantedAt: Date.now(), updatedAt: Date.now() }); + return old; + }); + const current = await f.detail(); + const input = { submissionId: f.intent.submissionId, expectedContributionVersion: current.contributionVersion, idempotencyKey: "replacement" }; + const p = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, input); + assert.equal(p.operationState, "committed"); + assert.deepEqual(await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, input), p); + const ownReview = await f.actor.query(api.profileMediaSubmissions.reviewDetail, { submissionId: p.resourceId! }); + assert.ok(ownReview); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: p.resourceId!, expectedReviewVersion: ownReview.reviewVersion, decision: "approve", privateReason: "Self", idempotencyKey: "self" })).code, "self_review"); + const reviewer = f.t.withIdentity(f.s.moderatorIdentity); + const review = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: p.resourceId! }); + assert.ok(review); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: p.resourceId!, expectedReviewVersion: review.reviewVersion, decision: "approve", privateReason: "Correct replacement", idempotencyKey: "independent" })).operationState, "committed"); + const active = await f.t.run(ctx => ctx.db.query("profileAssetPlacements").filter(q => q.eq(q.field("state"), "active")).collect()); + assert.ok(active.some(p => p.assetId === oldAssetId && p.placement === "gallery")); + assert.ok(active.some(p => p.assetId === d.assetId && p.placement === "gallery")); + assert.equal(active.find(p => p.placement === "profile_image")?.assetId, d.assetId); + assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").collect())).length, 12); + assert.deepEqual(await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()), beforeBytes); + assert.equal((await f.detail()).canClearPrimary, false); +}); + +it("treats visible automatic artwork as occupied and refuses hidden authored identity too", async () => { + const f = await fixture(); + await f.t.run(async ctx => { + const external = "usr_7023d326-083f-41fe-a3e9-27ea303b50c5"; + await ctx.db.patch(f.s.profileId, { outboundLinks: [{ type: "vrchat_profile", label: "VRChat", url: `https://vrchat.com/home/user/${external}`, source: "community_submitted" }] }); + await ctx.db.insert("profileLinkDestinations", { key: `vrchat_user:${external}`, kind: "vrchat_user", locator: external, provider: "vrchat", status: "resolved", artworkSourceUrl: "https://example.test/icon.webp", artworkType: "user_icon", observedAt: 1 }); + }); + assert.equal((await f.detail()).canSelectPrimary, false); + assert.equal((await f.place("select_primary")).code, "primary_occupied"); + await f.t.run(async ctx => { + await ctx.db.patch(f.s.profileId, { outboundLinks: [], fieldVisibility: { mediaKit: "private" } }); + const asset = await ctx.db.query("profileAssets").first(); + assert.ok(asset); + await selectProfileAssetIdentity(ctx.db, { profileId: f.s.profileId, assetId: asset._id, placement: "primary_logo", actorUserId: f.s.moderatorUserId, now: Date.now() }); + }); + assert.equal((await f.detail()).canSelectPrimary, false); + assert.equal((await f.place("select_primary")).code, "primary_occupied"); +}); + +it("does not interpret a rejected placement as a digest-wide content rejection", async () => { + const f = await fixture(); + const d = await f.detail(); + const p = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "proposal" }); + assert.equal(p.operationState, "committed"); + const reviewer = f.t.withIdentity(f.s.moderatorIdentity); + const review = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: p.resourceId! }); + assert.ok(review); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: p.resourceId!, expectedReviewVersion: review.reviewVersion, decision: "reject", privateReason: "Placement only", publicReason: "Declined", idempotencyKey: "reject" })).operationState, "committed"); + const second = await seed(f.t); + const { intent } = await createAndUpload(f.t, second); + await f.t.run(ctx => ctx.db.insert("accountFeatureGrants", { userId: second.contributorUserId, feature: "trusted_publisher", state: "active", grantedBy: { tokenIdentifier: "test:operator", issuer: "test", subject: "operator" }, grantedAt: Date.now(), updatedAt: Date.now() })); + const actor = f.t.withIdentity(second.contributorIdentity); + const pending = await actor.query(api.profileMediaSubmissions.publisherDetail, { submissionId: intent.submissionId }); + assert.ok(pending); + assert.equal((await actor.mutation(api.profileMediaSubmissions.publish, { submissionId: intent.submissionId, expectedReviewVersion: pending.reviewVersion, idempotencyKey: "another-publication" })).operationState, "committed"); +}); diff --git a/tests/backend/profile-foundation.test.ts b/tests/backend/profile-foundation.test.ts index e7287017..7d599657 100644 --- a/tests/backend/profile-foundation.test.ts +++ b/tests/backend/profile-foundation.test.ts @@ -2462,6 +2462,9 @@ describe("profile media kit asset helpers", () => { return id; }, + normalizeId(_tableName: string, id: string) { + return id; + }, async patch(id: string, patch: Record) { for (const rows of Object.values(tables)) { const row = rows.find((candidate) => candidate._id === id); @@ -2559,6 +2562,8 @@ describe("profile media kit asset helpers", () => { assert.equal(tables.profileAssets[0]?.label, "Primary logo"); assert.equal(tables.profileAssets[0]?.source, "owner_authored"); assert.equal(tables.profileAssetPlacements[0]?.placement, "primary_logo"); + assert.equal(tables.profileAssetPlacements[0]?.selectionActorUserId, "user123"); + assert.ok(tables.profileAssetPlacements[0]?.selectionOperationId); assert.equal(tables.profileAuditEvents[0]?.action, "api_profile_asset_uploaded"); assert.equal(tables.apiWriteAuditEvents[0]?.action, "profile_asset_upload_completed"); assert.equal(tables.apiWriteAuditEvents[0]?.actorKind, "upload_token"); From c137c2d1b593ab0c81afb1e2c002bb5f11880374 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 5 Oct 2026 18:56:59 -0400 Subject: [PATCH 08/28] fix: retain cleanup for uploaded identity proposals --- convex/_mediaReview.ts | 3 +- convex/profileMediaSubmissions.ts | 3 +- .../media-contribution-commands.test.ts | 2 ++ .../backend/profile-media-submissions.test.ts | 28 +++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/convex/_mediaReview.ts b/convex/_mediaReview.ts index d7616463..7abf8936 100644 --- a/convex/_mediaReview.ts +++ b/convex/_mediaReview.ts @@ -276,7 +276,8 @@ export async function applyReviewDecision( publicDisposition, privateReason, decisionProfileUpdatedAt: profile.updatedAt, - ...(submission.requestKind === "identity_placement" ? {} : { blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS }), + ...(submission.requestKind === "identity_placement" && submission.candidateAssetId !== undefined + ? {} : { blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS }), updatedAt: now, }); await ctx.db.insert("profileAuditEvents", { diff --git a/convex/profileMediaSubmissions.ts b/convex/profileMediaSubmissions.ts index bb7b6538..2a32ab5c 100644 --- a/convex/profileMediaSubmissions.ts +++ b/convex/profileMediaSubmissions.ts @@ -1679,7 +1679,8 @@ async function withdrawSubmission( const now = Date.now(); await ctx.db.patch(submission._id, { status: "withdrawn", - ...(submission.requestKind === "identity_placement" ? {} : { blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS }), + ...(submission.requestKind === "identity_placement" && submission.candidateAssetId !== undefined + ? {} : { blobDeleteAfter: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS }), updatedAt: now, }); await ctx.db.insert("profileAuditEvents", { diff --git a/tests/backend/media-contribution-commands.test.ts b/tests/backend/media-contribution-commands.test.ts index f540e61d..3bf62520 100644 --- a/tests/backend/media-contribution-commands.test.ts +++ b/tests/backend/media-contribution-commands.test.ts @@ -175,6 +175,7 @@ it("reviews existing-asset placement without a new upload or published charge", for (const disposition of ["reject", "withdraw"] as const) { it(`${disposition} leaves the published asset and its bytes alone`, async () => { const f = await fixture(); + const beforeCharge = await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()); const d = await f.detail(); const p = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "proposal" }); assert.equal(p.operationState, "committed"); @@ -190,6 +191,7 @@ for (const disposition of ["reject", "withdraw"] as const) { assert.equal((await f.t.run(ctx => ctx.db.query("mediaPublicationRestrictions").collect())).length, 0); assert.equal((await f.t.run(ctx => ctx.db.query("profileAssets").first()))?.state, "active"); assert.deepEqual(await f.placements(), ["gallery"]); + assert.deepEqual(await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()), beforeCharge); }); } diff --git a/tests/backend/profile-media-submissions.test.ts b/tests/backend/profile-media-submissions.test.ts index 88db5426..bebf88bb 100644 --- a/tests/backend/profile-media-submissions.test.ts +++ b/tests/backend/profile-media-submissions.test.ts @@ -16,6 +16,34 @@ process.env.VRDEX_PROFILE_MEDIA_KIT_ENABLED = "true"; import { modules, schema, NOW, FIRST_REVIEW_PAGE, TARGET_PROFILE_SNAPSHOT, seed, createAndUpload } from "./_mediaReviewFixture"; describe("unclaimed-profile media submissions", () => { + for (const disposition of ["reject", "withdraw"] as const) { + it(`schedules retained upload bytes after ${disposition} of an upload-backed identity proposal`, async () => { + const t = convexTest({ schema, modules }); + const seeded = await seed(t); + const { intent } = await createAndUpload(t, seeded); + await t.run(ctx => ctx.db.patch(intent.submissionId, { requestKind: "identity_placement" })); + if (disposition === "withdraw") { + assert.equal(await t.withIdentity(seeded.contributorIdentity).mutation( + api.profileMediaSubmissions.withdraw, { submissionId: intent.submissionId }, + ), true); + } else { + const reviewer = t.withIdentity(seeded.moderatorIdentity); + const detail = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: intent.submissionId }); + assert.ok(detail); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { + submissionId: intent.submissionId, expectedReviewVersion: detail.reviewVersion, + decision: "reject", privateReason: "Placement declined", publicReason: "Declined", idempotencyKey: "reject-uploaded-placement", + })).operationState, "committed"); + } + const stored = await t.run(ctx => ctx.db.get(intent.submissionId)); + assert.equal(stored?.status, disposition === "reject" ? "rejected" : "withdrawn"); + assert.equal(stored?.uploadIntentId, intent.intentId); + assert.equal(stored?.candidateAssetId, undefined); + assert.ok((stored?.blobDeleteAfter ?? 0) > Date.now()); + assert.ok((stored?.blobDeleteAfter ?? 0) <= Date.now() + 30 * 24 * 60 * 60 * 1000); + assert.equal((await t.run(ctx => ctx.db.get(intent.intentId)))?.state, "uploaded"); + }); + } it("does not revive a contribution withdrawn while its upload is processing", async () => { const t = convexTest({ schema, modules }); const seeded = await seed(t); From 3f083522adf7c7e5f01ac89c965c3c738de62fa0 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 5 Oct 2026 19:10:53 -0400 Subject: [PATCH 09/28] feat: manage own published media contributions --- convex/_mediaContributionCommands.ts | 83 +++++++-- convex/_mediaReview.ts | 6 +- convex/_profileAssets.ts | 3 + convex/profileMediaSubmissions.ts | 29 ++- convex/schema.ts | 2 + docs/engineering/media-review-commands.md | 20 +++ packages/api-contracts/src/media-review.ts | 14 ++ .../api-contracts/tests/media-review.test.ts | 11 ++ .../media-contribution-commands.test.ts | 169 +++++++++++++++++- 9 files changed, 318 insertions(+), 19 deletions(-) diff --git a/convex/_mediaContributionCommands.ts b/convex/_mediaContributionCommands.ts index 6ad9b5fe..f71d8492 100644 --- a/convex/_mediaContributionCommands.ts +++ b/convex/_mediaContributionCommands.ts @@ -2,16 +2,21 @@ import { ConvexError } from "convex/values"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { getAccountFeatureAccess } from "./_accountFeatures"; import { assertReviewActorVerified, hash, reviewSnapshot, type ReviewActor } from "./_mediaReview"; -import { selectProfileAssetIdentity, PROFILE_MEDIA_SUBMISSION_RETENTION_MS } from "./_profileAssets"; +import { selectProfileAssetIdentity, PROFILE_MEDIA_SUBMISSION_RETENTION_MS, + sanitizeProfileAssetLabel, sanitizeProfileAssetAltText, sanitizeProfileAssetCredit, + sanitizeProfileAssetCreditUrl, normalizeProfileAssetSourceUrl } from "./_profileAssets"; +import { isProfileFieldVisible } from "./_profileFieldVisibility"; import { effectiveContributionPolicy } from "./_contributionCapacity"; import { assertSubmissionRateLimits, openSubmissionCountForUser, openSubmissionCountForProfile } from "./profileMediaSubmissions"; import { contributionCommandBaseSchema, contributionPlacementCommandSchema, + contributionManageCommandSchema, type ContributionCommandBase, type ContributionPlacementCommand, + type ContributionManageCommand, type PublishedContributionDetail, type CommandReceipt, } from "../packages/api-contracts/src/media-review"; -async function contributionState(ctx: QueryCtx | MutationCtx, submissionId: string, actor: ReviewActor) { +async function contributionState(ctx: QueryCtx | MutationCtx, submissionId: string, actor: ReviewActor, allowDeleted = false) { assertReviewActorVerified(actor); const id = ctx.db.normalizeId("profileMediaSubmissions", submissionId); const submission = id ? await ctx.db.get(id) : null; @@ -20,7 +25,7 @@ async function contributionState(ctx: QueryCtx | MutationCtx, submissionId: stri if (!submission || !profile || !asset || submission.submitterUserId !== actor.user._id || submission.status !== "approved" || submission.requestKind === "identity_placement" || asset.sourceSubmissionId !== submission._id || asset.profileId !== profile._id || - asset.state !== "active" || asset.visibility !== "public" || asset.retiredAt !== undefined || asset.moderatorSuppressedAt !== undefined) + (!allowDeleted && asset.state !== "active") || asset.visibility !== "public" || asset.retiredAt !== undefined || asset.moderatorSuppressedAt !== undefined) throw new ConvexError({ code: "MEDIA_RESOURCE_UNAVAILABLE", message: "Published contribution unavailable." }); const placement = profile.profileType === "person" ? "profile_image" as const : "primary_logo" as const; const snapshot = await reviewSnapshot(ctx, { ...submission, requestKind: "identity_placement", candidateAssetId: asset._id, requestedPlacement: placement }, profile); @@ -39,22 +44,28 @@ async function contributionState(ctx: QueryCtx | MutationCtx, submissionId: stri const publisher = access.canPublishMedia && publicUnclaimed && !restrictions.some(Boolean); const ownSelection = !!current && current.assetId === asset._id && current.selectionActorUserId === actor.user._id && current.selectionOperationId?.startsWith("contribution-primary:") === true; - return { submission, profile, asset, placement, snapshot, current, identityPlacements, publisher, publicUnclaimed, restrictions, ownSelection, - version: await hash({ asset, source: submission, placement: current, picture: snapshot.currentImage, restrictions, publicUnclaimed, revision: snapshot.reviewVersion }) }; + const kitVisible = publicUnclaimed && isProfileFieldVisible(profile, "mediaKit", "profile_page"); + const assetPlacements = await ctx.db.query("profileAssetPlacements").withIndex("by_assetId_state", q => + q.eq("assetId", asset._id).eq("state", "active")).take(20); + const protectedSelection = assetPlacements.length === 20 || assetPlacements.some(row => row.placement !== "gallery" || + (row.selectionActorUserId !== undefined && row.selectionActorUserId !== actor.user._id)); + const ownKitOnly = kitVisible && !restrictions.some(Boolean) && asset.state === "active" && + !protectedSelection; + return { submission, profile, asset, placement, snapshot, current, identityPlacements, publisher, publicUnclaimed, restrictions, ownSelection, kitVisible, ownKitOnly, protectedSelection, + version: await hash({ asset, source: submission, placement: current, assetPlacements, picture: snapshot.currentImage, restrictions, publicUnclaimed, revision: snapshot.reviewVersion }) }; } export async function publishedContributionDetail(ctx: QueryCtx | MutationCtx, submissionId: string, actor: ReviewActor): Promise { const s = await contributionState(ctx, submissionId, actor); - const ownKitOnly = s.publicUnclaimed && !s.restrictions.some(Boolean) && - !s.identityPlacements.some(row => row.assetId === s.asset._id); + const metadata = s.kitVisible ? s.asset : s.submission; return { submissionId: s.submission._id, assetId: s.asset._id, profileId: s.profile._id, profileSlug: s.profile.slug, contributionVersion: s.version, - metadata: { label: s.asset.label ?? "Image", altText: s.asset.altText, credit: s.asset.credit ?? "", - creditUrl: s.asset.creditUrl, sourceUrl: s.asset.sourceUrl, sourceDescription: s.submission.sourceDescription }, + metadata: { label: metadata.label ?? "Image", altText: metadata.altText, credit: metadata.credit ?? "", + creditUrl: metadata.creditUrl, sourceUrl: metadata.sourceUrl, sourceDescription: metadata.sourceDescription }, canSelectPrimary: s.publisher && !s.identityPlacements.length && !s.snapshot.currentImage, canClearPrimary: s.publisher && s.ownSelection, - canEditMetadata: ownKitOnly, canRemove: ownKitOnly && s.submission.legalHoldAt === undefined, + canEditMetadata: s.ownKitOnly, canRemove: s.ownKitOnly && s.submission.legalHoldAt === undefined, }; } @@ -94,7 +105,7 @@ async function command(ctx: MutationCtx, input: ContributionCommandBase | Contri submitterUserId: actor.user._id, submitter: actor.subject, requestedPlacement: s.placement, targetProfileUpdatedAt: s.profile.updatedAt, targetPlacementAssetId: s.current?.assetId, targetPlacementVersion: s.snapshot.placementTargetVersion, - sourceUrl: s.asset.sourceUrl, sourceKind: s.submission.sourceKind, sourceDescription: s.submission.sourceDescription, + sourceUrl: s.asset.sourceUrl, sourceKind: s.submission.sourceKind, sourceDescription: s.asset.sourceDescription, credit: s.asset.credit ?? s.submission.credit, creditUrl: s.asset.creditUrl, label: s.asset.label, altText: s.asset.altText, contentSha256: s.asset.contentSha256, status: "submitted", expiresAt: now + PROFILE_MEDIA_SUBMISSION_RETENTION_MS, createdAt: now, updatedAt: now, @@ -108,3 +119,53 @@ async function command(ctx: MutationCtx, input: ContributionCommandBase | Contri } export function contributionPlacementCommand(ctx: MutationCtx, input: ContributionPlacementCommand, actor: ReviewActor) { return command(ctx, input, actor, false); } export function proposeContributionPlacement(ctx: MutationCtx, input: ContributionCommandBase, actor: ReviewActor) { return command(ctx, input, actor, true); } + +export async function contributionManageCommand(ctx: MutationCtx, input: ContributionManageCommand, actor: ReviewActor): Promise { + const args = contributionManageCommandSchema.parse(input); + // Logical deletion must still permit exact-input recovery of its durable receipt. + const s = await contributionState(ctx, args.submissionId, actor, true); + const inputHash = await hash({ command: "manage_contribution", ...args }); + const previous = await ctx.db.query("mediaReviewReceipts").withIndex("by_actorUserId_idempotencyKey", q => + q.eq("actorUserId", actor.user._id).eq("idempotencyKey", args.idempotencyKey)).unique(); + if (previous) { + if (!s.kitVisible || s.restrictions.some(Boolean) || s.protectedSelection) + return { operationId: previous.receipt.operationId, operationState: "refused", code: "authority_changed" }; + return previous.inputHash === inputHash ? previous.receipt : { operationId: previous.receipt.operationId, operationState: "refused", code: "idempotency_conflict" }; + } + let code = process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED !== "true" || process.env.VRDEX_PROFILE_MEDIA_KIT_ENABLED !== "true" ? "review_disabled" : + !s.kitVisible ? "target_unavailable" : s.restrictions.some(Boolean) ? "publication_restricted" : + !s.ownKitOnly ? "asset_protected" : args.action === "remove" && s.submission.legalHoldAt !== undefined ? "legal_hold" : + s.version !== args.expectedContributionVersion ? "contribution_changed" : undefined; + const before = { label: s.asset.label, altText: s.asset.altText, credit: s.asset.credit, + creditUrl: s.asset.creditUrl, sourceUrl: s.asset.sourceUrl, sourceDescription: s.asset.sourceDescription }; + const after = { ...before }; + if (!code && args.action === "update_metadata") { + try { + if (!Object.keys(args.metadata).length) throw new Error("Metadata change required."); + for (const key of ["label", "altText", "credit", "creditUrl", "sourceUrl", "sourceDescription"] as const) { + if (!Object.prototype.hasOwnProperty.call(args.metadata, key)) continue; + const value = args.metadata[key] ?? undefined; + after[key] = key === "label" ? sanitizeProfileAssetLabel(value) : + key === "altText" ? sanitizeProfileAssetAltText(value) : + key === "credit" ? sanitizeProfileAssetCredit(value) : + key === "creditUrl" ? sanitizeProfileAssetCreditUrl(value) : + key === "sourceUrl" ? normalizeProfileAssetSourceUrl(value) : value?.trim().replace(/\s+/g, " ") || undefined; + } + if (!after.label || !after.credit || (!after.sourceUrl && !after.sourceDescription)) throw new Error("Title, credit and provenance required."); + } catch { code = "invalid_metadata"; } + } + const receipt: CommandReceipt = { operationId: crypto.randomUUID(), operationState: code ? "refused" : "committed", + resourceId: s.submission._id, ...(code ? { code } : {}) }; + if (!code) { + const now = Date.now(); + const updatedAt = Math.max(now, s.asset.updatedAt + 1); + await ctx.db.patch(s.asset._id, args.action === "remove" ? { state: "deleted", deletedAt: now, updatedAt } : { ...after, updatedAt }); + await ctx.db.insert("profileAuditEvents", { profileId: s.profile._id, + action: args.action === "remove" ? "profile_media_contribution_removed" : "profile_media_contribution_metadata_updated", + actor: actor.subject, sourceType: "community", note: JSON.stringify({ assetId: s.asset._id, operationId: receipt.operationId, + before, after: args.action === "remove" ? { state: "deleted" } : after }), createdAt: now }); + } + await ctx.db.insert("mediaReviewReceipts", { actorUserId: actor.user._id, submissionId: s.submission._id, + idempotencyKey: args.idempotencyKey, inputHash, receipt, createdAt: Date.now() }); + return receipt; +} diff --git a/convex/_mediaReview.ts b/convex/_mediaReview.ts index 7abf8936..feb34a03 100644 --- a/convex/_mediaReview.ts +++ b/convex/_mediaReview.ts @@ -632,10 +632,10 @@ export async function reviewSnapshot( rendition: candidateReady ? { submissionId: submission._id, kind: "stored_candidate" as const } : null, - sourceUrl: submission.sourceUrl, + sourceUrl: existingCandidateReady ? candidateAsset.sourceUrl : submission.sourceUrl, sourceKind: submission.sourceKind, - sourceDescription: submission.sourceDescription, - credit: submission.credit, + sourceDescription: existingCandidateReady ? candidateAsset.sourceDescription : submission.sourceDescription, + credit: existingCandidateReady ? candidateAsset.credit ?? "" : submission.credit, contentSha256: submission.contentSha256 ?? null, }, }; diff --git a/convex/_profileAssets.ts b/convex/_profileAssets.ts index d628690f..4e370be5 100644 --- a/convex/_profileAssets.ts +++ b/convex/_profileAssets.ts @@ -825,6 +825,9 @@ export async function consumeProfileAssetUploads( validateProfileAssetGalleryPlacements(upload.placements, label); const assetId = await db.insert("profileAssets", { ...(input.approvedSubmissionId === undefined ? {} : { sourceSubmissionId: input.approvedSubmissionId }), + ...(input.approvedSubmissionId === undefined ? {} : { + sourceDescription: (await db.get(input.approvedSubmissionId))?.sourceDescription, + }), profileId: input.profileId, storageKey: intent.storageKey, ...(intent.sourceStorageKey !== undefined && intent.sourceContentSha256 !== undefined diff --git a/convex/profileMediaSubmissions.ts b/convex/profileMediaSubmissions.ts index 2a32ab5c..ea95ac10 100644 --- a/convex/profileMediaSubmissions.ts +++ b/convex/profileMediaSubmissions.ts @@ -1,5 +1,5 @@ import { assertLiveContributionToken } from "./_contributionAuth"; -import { publishedContributionDetail, contributionPlacementCommand, proposeContributionPlacement } from "./_mediaContributionCommands"; +import { publishedContributionDetail, contributionPlacementCommand, proposeContributionPlacement, contributionManageCommand } from "./_mediaContributionCommands"; import { effectiveContributionPolicy, contributionChargeRefusal, registerLegacyContribution, reservationBytes } from "./_contributionCapacity"; import { requirePublisher, publicationCommand, legacyDeclarationCommand, recordPublicationRestriction } from "./_trustedPublication"; import { readScopedPagination, writeScopedPagination } from "./_reviewCursor"; @@ -7,6 +7,7 @@ import type { PaginationOptions } from "convex/server"; import { activeBatchAssignment, hash } from "./_mediaReview"; import { reviewRebaseSchema, + contributionManageCommandSchema, type CommandReceipt, } from "../packages/api-contracts/src/media-review"; import { changeContributionCharge } from "./_contributionCapacity"; @@ -49,6 +50,11 @@ const contributionCommandArgs = { const placementCommandArgs = { ...contributionCommandArgs, action: v.union(v.literal("select_primary"), v.literal("clear_primary")), }; +const nullableText = v.optional(v.union(v.string(), v.null())); +const contributionMetadataPatch = v.object({ label: nullableText, altText: nullableText, credit: nullableText, + creditUrl: nullableText, sourceUrl: nullableText, sourceDescription: nullableText }); +const manageCommandArgs = { ...contributionCommandArgs, action: v.union(v.literal("update_metadata"), v.literal("remove")), + metadata: v.optional(contributionMetadataPatch) }; export const contributionDetail = query({ args: { submissionId }, handler: async (ctx, args) => publishedContributionDetail(ctx, args.submissionId, await browserReviewActor(ctx)) }); export const contributionDetailForMcpActor = internalQuery({ @@ -355,9 +361,18 @@ async function reviewSubmission( const submitter = includeModeratorEvidence ? await ctx.db.get(submission.submitterUserId) : null; + const candidateAsset = submission.requestKind === "identity_placement" && submission.candidateAssetId + ? await ctx.db.get(submission.candidateAssetId) : null; + const currentCandidate = candidateAsset?.profileId === profile._id && candidateAsset.state === "active" && + candidateAsset.visibility === "public" && candidateAsset.retiredAt === undefined && + candidateAsset.moderatorSuppressedAt === undefined && candidateAsset.contentSha256 === submission.contentSha256 + ? candidateAsset : null; return { ...publicSubmission(submission, profile), + ...(currentCandidate ? { label: currentCandidate.label, altText: currentCandidate.altText, + credit: currentCandidate.credit ?? "", creditUrl: currentCandidate.creditUrl, + sourceUrl: currentCandidate.sourceUrl, sourceDescription: currentCandidate.sourceDescription } : {}), priorProposalCount, priorProposalCountTruncated, canViewCandidate: @@ -2069,6 +2084,18 @@ const receiptValidator = v.object({ resourceId: v.optional(v.string()), code: v.optional(v.string()), }); +export const manageContribution = mutation({ + args: manageCommandArgs, + returns: receiptValidator, + handler: async (ctx, args) => contributionManageCommand(ctx, contributionManageCommandSchema.parse(args), await browserReviewActor(ctx)), +}); +export const manageContributionForMcpActor = internalMutation({ + args: { ...manageCommandArgs, actorUserId: v.id("users"), ...reviewActorAttestationArgs }, + returns: receiptValidator, + handler: async (ctx, { actorUserId, emailVerified, emailVerificationAttestedAt, oauthTokenId, oauthClientId, oauthResource, ...args }) => + contributionManageCommand(ctx, contributionManageCommandSchema.parse(args), await trustedReviewActor(ctx, actorUserId, + { emailVerified, emailVerificationAttestedAt, oauthTokenId, oauthClientId, oauthResource }, "assets:contribute")), +}); export const decideWithReceipt = mutation({ args: receiptDecisionArgs, returns: receiptValidator, diff --git a/convex/schema.ts b/convex/schema.ts index 6567b7c3..42196585 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -984,6 +984,7 @@ export default defineSchema({ .index("by_publicationMethod_actor", ["publicationMethod", "publicationActorUserId"]), profileAssets: defineTable({ sourceSubmissionId: v.optional(v.id("profileMediaSubmissions")), + sourceDescription: v.optional(v.string()), profileId: v.id("profiles"), storageKey: v.string(), sourceStorageKey: v.optional(v.string()), @@ -1030,6 +1031,7 @@ export default defineSchema({ state: profileAssetState, updatedAt: v.number(), }) + .index("by_assetId_state", ["assetId", "state"]) .index("by_profileId_placement_state_position", [ "profileId", "placement", diff --git a/docs/engineering/media-review-commands.md b/docs/engineering/media-review-commands.md index 91c22172..90d65ff7 100644 --- a/docs/engineering/media-review-commands.md +++ b/docs/engineering/media-review-commands.md @@ -66,6 +66,26 @@ these requests. Their authorized preview reads the existing stored asset. Meaningful evidence changes require fresh inspection. Placement changes also require an explicit rebase against the original selection snapshot. +`manageContribution` and internal `manageContributionForMcpActor` accept an own +approved submission ID, expected contribution version, idempotency key and either +`update_metadata` or `remove`. Ordinary contributors need no publisher grant. +MCP uses `assets:contribute` and `mcp:write` with current email/delegation checks. +Corrections allow only title, alt text, credit, credit URL, source URL and source +description. Optional values clear with `null`; title, credit and at least one +provenance value remain required. Actor and before/after metadata enter the audit. +The submission and publication evidence remain historical; published details and +existing-asset placement reviews project the current public asset metadata. +Corrections invalidate inspected placement review versions. + +Management requires a public, published, unclaimed target and visible public kit. +Non-gallery placement, another actor's selection, moderation restriction, +suppression or private media blocks contributor management. Legal holds block +removal. Removal uses the existing owner logical-delete state and timestamp, +excluding the item from public kit/download reads while retaining bytes, gallery +references, accounting and approved history. It schedules no permanent deletion. +Exact-input receipt replay recovers a removed item after response loss, with +current ownership, visibility and selection authority still checked. + Successful publication and its immutable receipt commit in one transaction. Expected terminal refusals also persist receipts. Projected active-public-asset capacity is checked before writes, with one new asset for each uploaded kit diff --git a/packages/api-contracts/src/media-review.ts b/packages/api-contracts/src/media-review.ts index b4edc030..08dfd99a 100644 --- a/packages/api-contracts/src/media-review.ts +++ b/packages/api-contracts/src/media-review.ts @@ -12,6 +12,20 @@ export const contributionPlacementCommandSchema = z.strictObject({ action: z.enum(["select_primary", "clear_primary"]), }); export type ContributionPlacementCommand = z.infer; +export const contributionMetadataPatchSchema = z.strictObject({ + label: z.string().max(80).nullable().optional(), + altText: z.string().max(180).nullable().optional(), + credit: z.string().max(120).nullable().optional(), + creditUrl: z.string().max(4096).nullable().optional(), + sourceUrl: z.string().max(4096).nullable().optional(), + sourceDescription: z.string().max(1000).nullable().optional(), +}); +export type ContributionMetadataPatch = z.infer; +export const contributionManageCommandSchema = z.discriminatedUnion("action", [ + z.strictObject({ ...contributionCommandBaseSchema.shape, action: z.literal("update_metadata"), metadata: contributionMetadataPatchSchema }), + z.strictObject({ ...contributionCommandBaseSchema.shape, action: z.literal("remove") }), +]); +export type ContributionManageCommand = z.infer; export const publishedContributionDetailSchema = z.strictObject({ submissionId: boundedId, assetId: boundedId, profileId: boundedId, profileSlug: z.string().max(200), contributionVersion: z.string().min(1).max(128), diff --git a/packages/api-contracts/tests/media-review.test.ts b/packages/api-contracts/tests/media-review.test.ts index d831aadf..15dff9be 100644 --- a/packages/api-contracts/tests/media-review.test.ts +++ b/packages/api-contracts/tests/media-review.test.ts @@ -5,9 +5,20 @@ import { reviewPageRequestSchema, contributionCommandBaseSchema, contributionPlacementCommandSchema, + contributionManageCommandSchema, publishedContributionDetailSchema, } from "../src/media-review"; import { apiScopes } from "../src/auth"; +it("bounds contribution metadata patches and forbids byte, authority, target and placement changes", () => { + const base = { submissionId: "submission", expectedContributionVersion: "version", idempotencyKey: "key" }; + assert.equal(contributionManageCommandSchema.safeParse({ ...base, action: "remove" }).success, true); + assert.equal(contributionManageCommandSchema.safeParse({ ...base, action: "update_metadata", metadata: { credit: null, altText: null } }).success, true); + for (const metadata of [{ label: "x".repeat(81) }, { sourceDescription: "x".repeat(1001) }, { credit: "x".repeat(121) }, + { altText: "x".repeat(181) }, { sourceUrl: "x".repeat(4097) }, { storageKey: "other" }, { visibility: "public" }, { profileId: "other" }, { placements: ["profile_image"] }]) + assert.equal(contributionManageCommandSchema.safeParse({ ...base, action: "update_metadata", metadata }).success, false); + assert.equal(contributionManageCommandSchema.safeParse({ ...base, action: "remove", metadata: {} }).success, false); + assert.equal(contributionManageCommandSchema.safeParse({ ...base, action: "update_metadata", metadata: {}, actorUserId: "other" }).success, false); +}); it("bounds contribution commands and refuses caller-selected capabilities", () => { const base = { submissionId: "submission", expectedContributionVersion: "version", idempotencyKey: "key" }; assert.equal(contributionCommandBaseSchema.safeParse(base).success, true); diff --git a/tests/backend/media-contribution-commands.test.ts b/tests/backend/media-contribution-commands.test.ts index 3bf62520..2250fcf4 100644 --- a/tests/backend/media-contribution-commands.test.ts +++ b/tests/backend/media-contribution-commands.test.ts @@ -3,13 +3,129 @@ import { it } from "node:test"; import { convexTest } from "convex-test"; import { api, internal } from "../../convex/_generated/api"; import { modules, schema, seed, createAndUpload } from "./_mediaReviewFixture"; -import { getProfileMediaVersion, selectProfileAssetIdentity } from "../../convex/_profileAssets"; +import { getProfileMediaVersion, getPublicProfileMediaKit, selectProfileAssetIdentity } from "../../convex/_profileAssets"; process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED = "true"; process.env.VRDEX_PROFILE_MEDIA_DIRECT_UPLOAD_ENABLED = "true"; process.env.VRDEX_PROFILE_MEDIA_KIT_ENABLED = "true"; -async function fixture(profileType: "person" | "community" = "person") { +it("corrects own approved kit metadata without publisher authority and keeps evidence immutable", async () => { + const f = await fixture(); + await f.t.run(ctx => ctx.db.patch(f.grant, { state: "revoked" })); + const before = await f.t.run(ctx => ctx.db.get(f.intent.submissionId)); + const d = await f.detail(); + const args = { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, + idempotencyKey: "correct", action: "update_metadata" as const, + metadata: { label: " Updated image ", altText: null, credit: " New credit ", sourceUrl: null, sourceDescription: "Local photograph" } }; + const first = await f.actor.mutation(api.profileMediaSubmissions.manageContribution, args); + assert.equal(first.operationState, "committed"); + assert.deepEqual(await f.actor.mutation(api.profileMediaSubmissions.manageContribution, args), first); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { ...args, metadata: { credit: "different" } })).code, "idempotency_conflict"); + const next = await f.detail(); + assert.notEqual(next.contributionVersion, d.contributionVersion); + assert.equal(next.metadata.label, "Updated image"); + assert.equal(next.metadata.credit, "New credit"); + assert.equal(next.metadata.altText, undefined); + assert.equal(next.metadata.sourceUrl, undefined); + assert.equal(next.metadata.sourceDescription, "Local photograph"); + assert.deepEqual(await f.t.run(ctx => ctx.db.get(f.intent.submissionId)), before); + const audits = await f.t.run(ctx => ctx.db.query("profileAuditEvents").collect()); + const correction = audits.filter(row => row.action === "profile_media_contribution_metadata_updated"); + assert.equal(correction.length, 1); + const audit = JSON.parse(correction[0].note!); + assert.equal(audit.before.credit, d.metadata.credit); + assert.equal(audit.after.credit, "New credit"); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { ...args, idempotencyKey: "stale" })).code, "contribution_changed"); +}); + +it("removes own kit item once, retains bytes/accounting/history and refuses key reuse for another item", async () => { + const f = await fixture(); + const d = await f.detail(); + const charge = await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()); + const args = { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "remove", action: "remove" as const }; + const first = await f.actor.mutation(api.profileMediaSubmissions.manageContribution, args); + assert.equal(first.operationState, "committed"); + assert.deepEqual(await f.actor.mutation(api.profileMediaSubmissions.manageContribution, args), first); + const removed = await f.t.run(ctx => ctx.db.get(d.assetId)); + assert.equal(removed?.state, "deleted"); + assert.ok(removed?.deletedAt); + assert.equal(removed?.retiredAt, undefined); + assert.deepEqual(await f.t.run(ctx => ctx.db.query("contributionUploadReservations").first()), charge); + const kit = await f.t.run(async ctx => getPublicProfileMediaKit(ctx.db, (await ctx.db.get(f.s.profileId))!)); + assert.equal(kit.galleryAssets.length, 0); + assert.equal((await f.actor.query(api.profileMediaSubmissions.getMine, { submissionId: f.intent.submissionId }))?.status, "approved"); + const second = await seed(f.t); + const otherId = await f.t.run(async ctx => { + const original = (await ctx.db.get(f.intent.submissionId))!; + const asset = (await ctx.db.get(d.assetId))!; + const { _id, _creationTime, ...fields } = original; + const id = await ctx.db.insert("profileMediaSubmissions", { ...fields, profileId: second.profileId }); + const { _id: assetId, _creationTime: assetCreatedAt, ...assetFields } = asset; + const newAsset = await ctx.db.insert("profileAssets", { ...assetFields, profileId: second.profileId, sourceSubmissionId: id, state: "active", deletedAt: undefined }); + await ctx.db.patch(id, { approvedAssetId: newAsset }); + return id; + }); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { ...args, submissionId: otherId })).code, "idempotency_conflict"); + assert.equal((await f.t.run(async ctx => ctx.db.get((await ctx.db.get(otherId))!.approvedAssetId!)))?.state, "active"); +}); + +for (const metadata of [{ credit: null }, { credit: " " }, { sourceUrl: null, sourceDescription: null }, { sourceUrl: "http://example.test" }, { creditUrl: "javascript:alert(1)" }, { label: null }, {}]) { + it(`refuses invalid correction ${JSON.stringify(metadata)}`, async () => { + const f = await fixture(); + const d = await f.detail(); + const result = await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { submissionId: f.intent.submissionId, + expectedContributionVersion: d.contributionVersion, idempotencyKey: "invalid", action: "update_metadata", metadata }); + assert.equal(result.operationState, "refused"); + assert.deepEqual(await f.detail(), d); + }); +} + +for (const condition of ["primary", "other_selection", "claimed", "private_profile", "private_asset", "hidden_kit", "suppressed", "held", "wrong_submitter"] as const) { + it(`protects contribution management after ${condition}`, async () => { + const f = await fixture(); + const d = await f.detail(); + await f.t.run(async ctx => { + if (condition === "primary" || condition === "other_selection") await selectProfileAssetIdentity(ctx.db, { profileId: f.s.profileId, assetId: d.assetId, placement: "profile_image", actorUserId: condition === "primary" ? f.s.contributorUserId : f.s.moderatorUserId, now: Date.now() }); + if (condition === "claimed") await ctx.db.patch(f.s.profileId, { claimState: "claimed_verified" }); + if (condition === "private_profile") await ctx.db.patch(f.s.profileId, { publicSurfacingState: "opted_out" }); + if (condition === "private_asset") await ctx.db.patch(d.assetId, { visibility: "private", credit: "Private owner credit" }); + if (condition === "hidden_kit") await ctx.db.patch(f.s.profileId, { fieldVisibility: { mediaKit: "private" } }); + if (condition === "suppressed") await ctx.db.patch(d.assetId, { moderatorSuppressedAt: Date.now() }); + if (condition === "held") await ctx.db.patch(f.intent.submissionId, { legalHoldAt: Date.now() }); + if (condition === "wrong_submitter") await ctx.db.patch(f.intent.submissionId, { submitterUserId: f.s.moderatorUserId }); + }); + const args = { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "blocked", action: "remove" as const }; + if (condition === "private_asset" || condition === "suppressed" || condition === "wrong_submitter") await assert.rejects(f.actor.mutation(api.profileMediaSubmissions.manageContribution, args), /unavailable/i); + else assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, args)).operationState, "refused"); + assert.equal((await f.t.run(ctx => ctx.db.get(d.assetId)))?.state, "active"); + if (condition !== "wrong_submitter") { + const history = await f.actor.query(api.profileMediaSubmissions.getMine, { submissionId: f.intent.submissionId }); + assert.equal(history?.status, "approved"); + assert.ok(!JSON.stringify(history).includes("Private owner credit")); + } + }); +} + +it("metadata correction invalidates inspected placement review and refreshes its candidate projection", async () => { + const f = await fixture(); + const d = await f.detail(); + const proposal = await f.actor.mutation(api.profileMediaSubmissions.proposePlacement, { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "proposal" }); + const reviewer = f.t.withIdentity(f.s.moderatorIdentity); + const before = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: proposal.resourceId! }); + assert.ok(before); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { submissionId: f.intent.submissionId, + expectedContributionVersion: d.contributionVersion, idempotencyKey: "correct", action: "update_metadata", metadata: { credit: "Corrected credit", sourceUrl: "https://corrected.example/source" } })).operationState, "committed"); + const after = await reviewer.query(api.profileMediaSubmissions.reviewDetail, { submissionId: proposal.resourceId! }); + assert.ok(after); + assert.notEqual(after.reviewVersion, before.reviewVersion); + assert.equal(after.candidate.credit, "Corrected credit"); + assert.equal(after.candidate.sourceUrl, "https://corrected.example/source"); + assert.equal(after.credit, "Corrected credit"); + assert.equal(after.sourceUrl, "https://corrected.example/source"); + assert.equal((await reviewer.mutation(api.profileMediaSubmissions.decideWithReceipt, { submissionId: proposal.resourceId!, expectedReviewVersion: before.reviewVersion, decision: "approve", privateReason: "Checked", idempotencyKey: "stale" })).code, "review_changed"); +}); + +async function fixture(profileType: "person" | "community" = "person", independentlyApproved = false) { const t = convexTest({ schema, modules }); const s = await seed(t, profileType); const { intent } = await createAndUpload(t, s); @@ -19,10 +135,13 @@ async function fixture(profileType: "person" | "community" = "person") { grantedAt: Date.now(), updatedAt: Date.now(), })); const actor = t.withIdentity(s.contributorIdentity); - const pending = await actor.query(api.profileMediaSubmissions.publisherDetail, { submissionId: intent.submissionId }); + if (independentlyApproved) await t.run(ctx => ctx.db.patch(grant, { state: "revoked" })); + const reviewer = independentlyApproved ? t.withIdentity(s.moderatorIdentity) : actor; + const pending = await reviewer.query(independentlyApproved ? api.profileMediaSubmissions.reviewDetail : api.profileMediaSubmissions.publisherDetail, { submissionId: intent.submissionId }); assert.ok(pending); - assert.equal((await actor.mutation(api.profileMediaSubmissions.publish, { + assert.equal((await reviewer.mutation(independentlyApproved ? api.profileMediaSubmissions.decideWithReceipt : api.profileMediaSubmissions.publish, { submissionId: intent.submissionId, expectedReviewVersion: pending.reviewVersion, idempotencyKey: "publish", + ...(independentlyApproved ? { decision: "approve", privateReason: "Independently reviewed" } : {}), })).operationState, "committed"); await t.run(ctx => ctx.db.patch(intent.submissionId, { createdAt: Date.now() - 60_000 })); const detail = () => actor.query(api.profileMediaSubmissions.contributionDetail, { submissionId: intent.submissionId }); @@ -36,6 +155,48 @@ async function fixture(profileType: "person" | "community" = "person") { return { t, s, intent, grant, actor, detail, place, placements }; } +it("allows ordinary independently approved contributors to correct and remove their own kit items", async () => { + const f = await fixture("person", true); + assert.equal((await f.detail()).canEditMetadata, true); + const d = await f.detail(); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { submissionId: f.intent.submissionId, + expectedContributionVersion: d.contributionVersion, idempotencyKey: "correct", action: "update_metadata", metadata: { credit: "Corrected credit" } })).operationState, "committed"); + const fresh = await f.detail(); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { submissionId: f.intent.submissionId, + expectedContributionVersion: fresh.contributionVersion, idempotencyKey: "remove", action: "remove" })).operationState, "committed"); +}); + +it("protects non-kit placement selections and exposes no current metadata for a hidden kit", async () => { + const f = await fixture(); + const d = await f.detail(); + await f.t.run(async ctx => { + await ctx.db.insert("profileAssetPlacements", { profileId: f.s.profileId, assetId: d.assetId, placement: "banner", position: 0, + state: "active", selectionActorUserId: f.s.moderatorUserId, selectionOperationId: "owner", updatedAt: Date.now() }); + await ctx.db.patch(d.assetId, { credit: "Private owner metadata" }); + }); + assert.equal((await f.detail()).canEditMetadata, false); + assert.equal((await f.actor.mutation(api.profileMediaSubmissions.manageContribution, { submissionId: f.intent.submissionId, + expectedContributionVersion: d.contributionVersion, idempotencyKey: "protected", action: "update_metadata", metadata: { credit: "Take over" } })).operationState, "refused"); + await f.t.run(ctx => ctx.db.patch(f.s.profileId, { fieldVisibility: { mediaKit: "private" } })); + assert.ok(!JSON.stringify(await f.detail()).includes("Private owner metadata")); +}); + +it("attested MCP contributors manage with contribute scope and lose replay after delegation revocation", async () => { + const f = await fixture("person", true); + const d = await f.detail(); + const tokenId = await f.t.run(ctx => ctx.db.insert("oauthAccessTokens", { tokenId: "manage-token", clientId: "client", subjectType: "user", + userId: f.s.contributorUserId, resource: "https://example.test/mcp", scopes: ["mcp:read", "mcp:write", "assets:contribute"], status: "active", issuedAt: Date.now(), expiresAt: Date.now() + 60_000 })); + const args = { submissionId: f.intent.submissionId, expectedContributionVersion: d.contributionVersion, idempotencyKey: "mcp-correct", + action: "update_metadata" as const, metadata: { credit: "MCP credit" }, actorUserId: f.s.contributorUserId, + oauthTokenId: "manage-token", oauthClientId: "client", emailVerified: true, emailVerificationAttestedAt: Date.now() }; + await assert.rejects(f.t.mutation(internal.profileMediaSubmissions.manageContributionForMcpActor, { ...args, emailVerified: false }), /verified/i); + const first = await f.t.mutation(internal.profileMediaSubmissions.manageContributionForMcpActor, args); + assert.equal(first.operationState, "committed"); + assert.deepEqual(await f.t.mutation(internal.profileMediaSubmissions.manageContributionForMcpActor, args), first); + await f.t.run(ctx => ctx.db.patch(tokenId, { status: "revoked" })); + await assert.rejects(f.t.mutation(internal.profileMediaSubmissions.manageContributionForMcpActor, args), /DENIED/); +}); + for (const profileType of ["person", "community"] as const) { it(`selects and clears its own ${profileType} primary while keeping gallery and bytes`, async () => { const f = await fixture(profileType); From abb94e3ed382ea3c81b3fad391d1771e72d14868 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 5 Oct 2026 19:30:03 -0400 Subject: [PATCH 10/28] feat: expose media kit contribution actions through MCP --- .../media-review-assigned.stories.tsx | 2 + apps/web/src/lib/server/mcp-media-review.ts | 29 +++- apps/web/src/lib/server/vrdex-mcp.ts | 126 +++++++++++++----- convex/_apiWriteAuditEvents.ts | 3 + convex/_mcpToolEvents.ts | 4 + scripts/smoke-mcp-inspector-client.ts | 5 +- scripts/smoke-vrdex-mcp-compat.ts | 34 ++++- tests/scripts/mcp-compat-smoke-cli.test.ts | 17 ++- tests/scripts/mcp-inspector-smoke.test.ts | 16 ++- tests/web/mcp-media-review.test.ts | 24 ++++ tests/web/vrdex-mcp.test.ts | 101 +++++++++++++- 11 files changed, 319 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/media-review-assigned.stories.tsx b/apps/web/src/components/media-review-assigned.stories.tsx index 2ccdee9a..7c304aa3 100644 --- a/apps/web/src/components/media-review-assigned.stories.tsx +++ b/apps/web/src/components/media-review-assigned.stories.tsx @@ -19,6 +19,8 @@ const row: Props["row"] = { profileType: "person", profileIsPublic: true, requestedPlacement: "profile_image", + requestKind: undefined, + candidateAssetId: undefined, status: "submitted", sourceKind: "local", sourceDescription: description, diff --git a/apps/web/src/lib/server/mcp-media-review.ts b/apps/web/src/lib/server/mcp-media-review.ts index e0b782b4..e769fdc9 100644 --- a/apps/web/src/lib/server/mcp-media-review.ts +++ b/apps/web/src/lib/server/mcp-media-review.ts @@ -1,5 +1,9 @@ import { commandReceiptSchema, + contributionCommandBaseSchema, + contributionManageCommandSchema, + contributionPlacementCommandSchema, + publishedContributionDetailSchema, mediaPublicationSchema, publicationEvidenceSchema, reviewRebaseSchema, @@ -73,13 +77,16 @@ export const mediaSubmissionWriteToolNames = [ "vrdex_media_submission_withdraw", ] as const; -type ReviewQueryName = "list" | "detail" | "candidate" | "current"; +type ReviewQueryName = "list" | "detail" | "candidate" | "current" | "contributionGet"; type ReviewMutationName = | "decide" | "withdraw" | "rebase" | "publish" - | "declare"; + | "declare" + | "contributionManage" + | "contributionPlace" + | "contributionProposePlacement"; type StoredObject = { body: Uint8Array; contentType: string; @@ -308,6 +315,24 @@ export function createMcpMediaReviewHandlers( }; }, + async contributionGet(input: unknown): Promise { + const value = mediaReviewGetInputSchema.parse(input); + const result = await dependencies.query("contributionGet", { ...(await attestation(dependencies)), ...value }); + if (result === null) return refusal("Contribution unavailable."); + return jsonResult(publishedContributionDetailSchema.parse(result)); + }, + async contributionManage(input: unknown): Promise { + const value = contributionManageCommandSchema.parse(input); + return jsonResult(commandReceiptSchema.parse(await dependencies.mutate("contributionManage", { ...(await attestation(dependencies)), ...value }))); + }, + async contributionPlace(input: unknown): Promise { + const value = contributionPlacementCommandSchema.parse(input); + return jsonResult(commandReceiptSchema.parse(await dependencies.mutate("contributionPlace", { ...(await attestation(dependencies)), ...value }))); + }, + async contributionProposePlacement(input: unknown): Promise { + const value = contributionCommandBaseSchema.parse(input); + return jsonResult(commandReceiptSchema.parse(await dependencies.mutate("contributionProposePlacement", { ...(await attestation(dependencies)), ...value }))); + }, async publish(input: unknown): Promise { return jsonResult( commandReceiptSchema.parse( diff --git a/apps/web/src/lib/server/vrdex-mcp.ts b/apps/web/src/lib/server/vrdex-mcp.ts index ebe18adc..d2575b33 100644 --- a/apps/web/src/lib/server/vrdex-mcp.ts +++ b/apps/web/src/lib/server/vrdex-mcp.ts @@ -12,6 +12,7 @@ import { fromJsonSchema, type AuthInfo, type McpHttpHandler, + type Tool, McpServer, } from "@modelcontextprotocol/server"; import { api, internal } from "@convex-generated-api"; @@ -25,6 +26,10 @@ import { ApiProfileUpdateRequestSchema, ApiProfileWriteResponseSchema, commandReceiptSchema, + contributionCommandBaseSchema, + contributionManageCommandSchema, + contributionPlacementCommandSchema, + publishedContributionDetailSchema, ProfileAssetPlacementSchema, type ApiScope, getBearerTokenFromAuthorizationHeader, @@ -143,6 +148,9 @@ const mcpWriteToolNames = [ ...mediaReviewWriteToolNames, ...mediaSubmissionWriteToolNames, "vrdex_media_submission_publish", + "vrdex_media_contribution_manage", + "vrdex_media_contribution_place", + "vrdex_media_contribution_propose_placement", "vrdex_media_submission_declare", "vrdex_contribution_capacity_request", "vrdex_contribution_batch_create", @@ -182,6 +190,9 @@ const mcpWriteToolResourceScopes: Record<(typeof mcpWriteToolNames)[number], Api vrdex_media_review_decide_selected: "assets:review:write", vrdex_media_submission_withdraw: "assets:contribute", vrdex_media_submission_publish: "assets:publish", + vrdex_media_contribution_manage: "assets:contribute", + vrdex_media_contribution_place: "assets:publish", + vrdex_media_contribution_propose_placement: "assets:contribute", vrdex_media_submission_declare: "assets:publish", vrdex_contribution_capacity_request: "assets:contribute", vrdex_contribution_batch_create: "assets:contribute", @@ -213,6 +224,7 @@ const mcpOwnedReadToolNames = [ "vrdex_media_review_assignments", ...mediaReviewReadToolNames, "vrdex_media_submission_get", + "vrdex_media_contribution_get", "vrdex_media_submission_preview", ] as const; const mcpOwnedReadToolScopes: Record<(typeof mcpOwnedReadToolNames)[number], ApiScope> = { @@ -228,6 +240,7 @@ const mcpOwnedReadToolScopes: Record<(typeof mcpOwnedReadToolNames)[number], Api vrdex_get_my_media_submission: "assets:contribute", vrdex_media_review_assignments: "assets:review:read", vrdex_media_submission_get: "assets:publish", + vrdex_media_contribution_get: "assets:contribute", vrdex_media_submission_preview: "assets:publish", vrdex_media_review_list: "assets:review:read", vrdex_media_review_get: "assets:review:read", @@ -440,7 +453,7 @@ const mcpProfileMediaSubmissionSchema = z.object({ submissionId: z.string().min(1), profileSlug: mcpSlugSchema, profileDisplayName: z.string().min(1), - requestedPlacement: z.enum(["profile_image", "primary_logo"]), + requestedPlacement: z.enum(["profile_image", "primary_logo", "gallery"]), status: mcpProfileMediaSubmissionStatusSchema, publicDisposition: z.string().optional(), approvedAssetId: z.string().min(1).optional(), @@ -1079,6 +1092,9 @@ const mcpMediaReviewWriteToolNames = new Set([ ...mediaReviewWriteToolNames, ...mediaSubmissionWriteToolNames, "vrdex_media_submission_publish", + "vrdex_media_contribution_manage", + "vrdex_media_contribution_place", + "vrdex_media_contribution_propose_placement", "vrdex_media_submission_declare", ]); const definiteMediaReviewErrorCodes = new Map) => { + const registered = server.registerTool(...args); + const [name] = args; + if (name !== "vrdex_media_submission_declare") { + advertisedTools.push({ + name, title: registered.title, description: registered.description, + inputSchema: { type: "object", ...(registered.inputSchema?.["~standard"].jsonSchema.input({ target: "draft-2020-12" }) ?? { properties: {} }) } as Tool["inputSchema"], + ...(registered.outputSchema ? { outputSchema: registered.outputSchemaJson as Tool["outputSchema"] } : {}), + annotations: registered.annotations, icons: registered.icons, + execution: registered.execution, _meta: registered._meta, + }); + } + return registered; + }) as typeof server.registerTool; for (const operation of Object.keys(eventIntakeOperations) as EventIntakeOperation[]) { const contract = eventIntakeOperations[operation]; const readOnly = operation === "draft_get" || operation === "event_get"; const toolName = `vrdex_event_intake_${operation}` as const; const scopes: ApiScope[] = [readOnly ? "mcp:read" : "mcp:write", "events:contribute"]; - server.registerTool(toolName, { + registerTool(toolName, { title: toolName, description: `Event intake ${operation.replaceAll("_", " ")}.`, inputSchema: fromJsonSchema>(mcpOutputJsonSchemaForZodSchema(contract.input)), outputSchema: mcpOutputSchema>(contract.output), annotations: { readOnlyHint: readOnly, destructiveHint: operation === "event_retract", idempotentHint: readOnly || operation === "publish" }, @@ -2140,7 +2173,8 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { } const submissionId = args.submissionId as Id<"profileMediaSubmissions">; return await adminConvex().query( - name === "detail" + name === "contributionGet" ? internal.profileMediaSubmissions.contributionDetailForMcpActor + : name === "detail" ? publisher ? internal.profileMediaSubmissions.publisherDetailForMcpActor : internal.profileMediaSubmissions.reviewDetailForMcpActor : name === "current" ? publisher @@ -2159,7 +2193,10 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { mutate: async (name, args) => { const submissionId = args.submissionId as Id<"profileMediaSubmissions">; return await adminConvex().mutation( - name === "publish" ? internal.profileMediaSubmissions.publishForMcpActor + name === "contributionManage" ? internal.profileMediaSubmissions.manageContributionForMcpActor + : name === "contributionPlace" ? internal.profileMediaSubmissions.placeContributionForMcpActor + : name === "contributionProposePlacement" ? internal.profileMediaSubmissions.proposePlacementForMcpActor + : name === "publish" ? internal.profileMediaSubmissions.publishForMcpActor : name === "declare" ? internal.profileMediaSubmissions.declarePublicationEvidenceForMcpActor : name === "decide" ? internal.profileMediaSubmissions.decideForMcpActor @@ -2328,7 +2365,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { } satisfies PublicSearchResponse; } - server.registerTool( + registerTool( "search", { title: "Search VRDex Documents", @@ -2355,7 +2392,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "fetch", { title: "Fetch VRDex Document", @@ -2405,7 +2442,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_search", { title: "Search VRDex", @@ -2432,7 +2469,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_get_profile", { title: "Get VRDex Profile", @@ -2466,7 +2503,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_list_my_profiles", { title: "List My VRDex Profiles", @@ -2529,7 +2566,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_list_my_media_submissions", { title: "List My VRDex Media Submissions", @@ -2608,7 +2645,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { ) as ContributionOperation[]) { const toolName = collectionToolNames[operation]; const read = ["get", "items", "capacity", "requests", "status"].includes(operation); - server.registerTool( + registerTool( toolName, { title: operation, @@ -2665,7 +2702,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { for (const operation of ["begin", "complete"] as const) { const toolName = operation === "begin" ? "vrdex_media_upload_begin" : "vrdex_media_upload_complete"; - server.registerTool(toolName, { + registerTool(toolName, { title: operation === "begin" ? "Begin Media Upload" : "Complete Media Upload", description: operation === "begin" ? "Reserve a local image upload and return its multipart transfer fields." : "Finalize an uploaded image as owner media or a private contribution.", inputSchema: operation === "begin" ? localUploadRequestSchema : localUploadCompleteSchema, @@ -2702,7 +2739,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { for (const operation of ["get", "preview"] as const) { const toolName = operation === "get" ? "vrdex_media_submission_get" : "vrdex_media_submission_preview"; - server.registerTool(toolName, { + registerTool(toolName, { title: operation === "get" ? "Contribution" : "Preview", description: toolName, inputSchema: operation === "get" ? mediaReviewGetInputSchema : mediaReviewPreviewInputSchema, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, @@ -2712,9 +2749,18 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { return principal === null ? mcpOwnedReadUnauthorized(toolName) : await mediaReviewHandlersFor(principal, true)[operation](input); }); } + registerTool("vrdex_media_contribution_get", { + title: "Contribution", description: "vrdex_media_contribution_get", + inputSchema: mediaReviewGetInputSchema, outputSchema: mcpOutputSchema(publishedContributionDetailSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + _meta: { securitySchemes: mcpOwnedReadSecuritySchemes("vrdex_media_contribution_get") }, + }, async input => { + const principal = ownedReadPrincipalFor("vrdex_media_contribution_get"); + return principal === null ? mcpOwnedReadUnauthorized("vrdex_media_contribution_get") : await mediaReviewHandlersFor(principal).contributionGet(input); + }); for (const operation of ["publish", "declare"] as const) { const toolName = operation === "publish" ? "vrdex_media_submission_publish" : "vrdex_media_submission_declare"; - server.registerTool(toolName, { + registerTool(toolName, { title: operation === "publish" ? "Publish" : "Confirm evidence", description: toolName, inputSchema: operation === "publish" ? mediaPublicationSchema : publicationEvidenceSchema, outputSchema: mcpOutputSchema(commandReceiptSchema), @@ -2727,11 +2773,26 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }); } + for (const [toolName, operation, inputSchema, title] of [ + ["vrdex_media_contribution_manage", "contributionManage", contributionManageCommandSchema, "Manage contribution"], + ["vrdex_media_contribution_place", "contributionPlace", contributionPlacementCommandSchema, "Profile picture"], + ["vrdex_media_contribution_propose_placement", "contributionProposePlacement", contributionCommandBaseSchema, "Request replacement"], + ] as const) { + registerTool(toolName, { + title, description: toolName, inputSchema, outputSchema: mcpOutputSchema(commandReceiptSchema), + annotations: { readOnlyHint: false, destructiveHint: operation === "contributionManage", idempotentHint: true, openWorldHint: false }, + _meta: { securitySchemes: mcpWriteSecuritySchemes(toolName) }, + }, async (input: unknown) => { + const principal = principalFor(toolName); + return principal === null ? mcpWriteUnauthorized(toolName) : await recordNewWrite(toolName, principal, input, () => mediaReviewHandlersFor(principal)[operation](input)); + }); + } + for (const assigned of [false, true]) { const name = assigned ? "vrdex_media_review_assignments" : "vrdex_get_my_media_submission"; - server.registerTool( + registerTool( name, { title: assigned ? "Assigned batches" : "My contribution", @@ -2797,7 +2858,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { ); } - server.registerTool( + registerTool( "vrdex_media_review_list", { title: "List Media Reviews", @@ -2815,7 +2876,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_media_review_get", { title: "Get Media Review", @@ -2833,7 +2894,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_media_review_preview", { title: "Preview Media Review", @@ -2851,7 +2912,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_media_review_decide", { title: "Decide Media Review", @@ -2869,20 +2930,20 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool("vrdex_media_review_rebase",{ + registerTool("vrdex_media_review_rebase",{ title:"Rebase",description:"vrdex_media_review_rebase",inputSchema:reviewRebaseSchema, outputSchema:mcpOutputSchema(commandReceiptSchema), annotations:{readOnlyHint:false,destructiveHint:false,idempotentHint:true,openWorldHint:false}, _meta:{securitySchemes:mcpWriteSecuritySchemes("vrdex_media_review_rebase")}, },async input=>{const principal=principalFor("vrdex_media_review_rebase");return principal===null?mcpWriteUnauthorized("vrdex_media_review_rebase"):await recordNewWrite("vrdex_media_review_rebase",principal,input,()=>mediaReviewHandlersFor(principal).rebase(input));}); - server.registerTool("vrdex_media_review_decide_selected",{ + registerTool("vrdex_media_review_decide_selected",{ title:"Decide selected",description:"vrdex_media_review_decide_selected",inputSchema:selectedReviewDecisionsSchema, outputSchema:mcpOutputSchema(z.strictObject({receipts:z.array(commandReceiptSchema).min(1).max(20)})), annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:true,openWorldHint:false}, _meta:{securitySchemes:mcpWriteSecuritySchemes("vrdex_media_review_decide_selected")}, },async input=>{const principal=principalFor("vrdex_media_review_decide_selected");return principal===null?mcpWriteUnauthorized("vrdex_media_review_decide_selected"):await recordNewWrite("vrdex_media_review_decide_selected",principal,input,()=>mediaReviewHandlersFor(principal).decideSelected(input));}); - server.registerTool( + registerTool( "vrdex_media_submission_withdraw", { title: "Withdraw Media Submission", @@ -2900,7 +2961,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_get_event", { title: "Get VRDex Event", @@ -2929,7 +2990,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_list_upcoming_events", { title: "List VRDex Upcoming Events", @@ -2957,7 +3018,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_get_world", { title: "Get VRDex World", @@ -2986,7 +3047,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_list_active_worlds", { title: "List VRDex Active Worlds", @@ -3012,7 +3073,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_event_create", { title: "Create VRDex Event", @@ -3121,7 +3182,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_event_update", { title: "Update VRDex Event", @@ -3231,7 +3292,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_profile_update", { title: "Update VRDex Profile", @@ -3304,7 +3365,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_profile_submit", { title: "Submit VRDex Community Profile", @@ -3368,7 +3429,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_profile_media_submit", { title: "Submit VRDex Profile Media", @@ -3584,7 +3645,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); - server.registerTool( + registerTool( "vrdex_profile_media_manage", { title: "Manage VRDex Profile Media", @@ -3872,6 +3933,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }, ); + server.server.setRequestHandler("tools/list", () => ({ tools: advertisedTools })); return server; } diff --git a/convex/_apiWriteAuditEvents.ts b/convex/_apiWriteAuditEvents.ts index 07d217b7..ade67c64 100644 --- a/convex/_apiWriteAuditEvents.ts +++ b/convex/_apiWriteAuditEvents.ts @@ -69,6 +69,9 @@ export const mcpWriteToolNameValidator = v.union( v.literal("vrdex_media_review_decide_selected"), v.literal("vrdex_media_submission_withdraw"), v.literal("vrdex_media_submission_publish"), + v.literal("vrdex_media_contribution_manage"), + v.literal("vrdex_media_contribution_place"), + v.literal("vrdex_media_contribution_propose_placement"), v.literal("vrdex_media_submission_declare"), v.literal("vrdex_contribution_capacity_request"), v.literal("vrdex_contribution_capacity"), diff --git a/convex/_mcpToolEvents.ts b/convex/_mcpToolEvents.ts index da61785d..f923ba99 100644 --- a/convex/_mcpToolEvents.ts +++ b/convex/_mcpToolEvents.ts @@ -33,6 +33,10 @@ export const mcpToolNameValidator = v.union( v.literal("vrdex_media_review_decide_selected"), v.literal("vrdex_media_submission_withdraw"), v.literal("vrdex_media_submission_publish"), + v.literal("vrdex_media_contribution_manage"), + v.literal("vrdex_media_contribution_place"), + v.literal("vrdex_media_contribution_propose_placement"), + v.literal("vrdex_media_contribution_get"), v.literal("vrdex_media_submission_declare"), v.literal("vrdex_contribution_capacity_request"), v.literal("vrdex_contribution_capacity"), diff --git a/scripts/smoke-mcp-inspector-client.ts b/scripts/smoke-mcp-inspector-client.ts index c0e32b5c..af53da2d 100644 --- a/scripts/smoke-mcp-inspector-client.ts +++ b/scripts/smoke-mcp-inspector-client.ts @@ -8,7 +8,7 @@ import { mcpOAuthClientCredentialsFromEnv, mcpOAuthClientCredentialsFromOptions, } from "./mcp-oauth-client-credentials"; -import { assertHostedToolSecuritySchemes, hostedExpectedToolNames } from "./smoke-vrdex-mcp-compat"; +import { assertHostedContributionSchemas, assertHostedToolSecuritySchemes, hostedExpectedToolNames } from "./smoke-vrdex-mcp-compat"; type InspectorOptions = { hostedDataPublicReads: boolean; @@ -35,6 +35,8 @@ type ToolDescriptor = { securitySchemes?: unknown; }; name?: unknown; + inputSchema?: unknown; + outputSchema?: unknown; }; type InspectorSearchResult = { @@ -282,6 +284,7 @@ function assertHostedTools(body: { tools?: ToolDescriptor[] }, label: string) { assertExpectedHostedToolNames(toolNames); for (const tool of body.tools ?? []) { assertHostedToolSecuritySchemes(tool); + assertHostedContributionSchemas(tool); } } diff --git a/scripts/smoke-vrdex-mcp-compat.ts b/scripts/smoke-vrdex-mcp-compat.ts index 93eb65b2..0ee509b4 100644 --- a/scripts/smoke-vrdex-mcp-compat.ts +++ b/scripts/smoke-vrdex-mcp-compat.ts @@ -46,6 +46,8 @@ type SmokeOptions = { type HostedToolDescriptor = { _meta?: unknown; name?: unknown; + inputSchema?: unknown; + outputSchema?: unknown; }; const localReadTools = [ @@ -84,7 +86,9 @@ const writeToolResourceScopes: Record = { vrdex_media_review_decide_selected: "assets:review:write", vrdex_media_submission_withdraw: "assets:contribute", vrdex_media_submission_publish: "assets:publish", - vrdex_media_submission_declare: "assets:publish", + vrdex_media_contribution_manage: "assets:contribute", + vrdex_media_contribution_place: "assets:publish", + vrdex_media_contribution_propose_placement: "assets:contribute", }; const writeToolNames = Object.keys(writeToolResourceScopes); const contributionCollectionWriteToolNames = new Set([ @@ -114,7 +118,9 @@ const hostedOnlyWriteToolNames = new Set([ "vrdex_media_review_decide_selected", "vrdex_media_submission_withdraw", "vrdex_media_submission_publish", - "vrdex_media_submission_declare", + "vrdex_media_contribution_manage", + "vrdex_media_contribution_place", + "vrdex_media_contribution_propose_placement", ]); const localWriteToolNames = writeToolNames.filter((toolName) => !hostedOnlyWriteToolNames.has(toolName)); // Reads, but of the caller's own inventory, so they advertise a scope pair the @@ -140,6 +146,7 @@ const ownedReadToolScopes: Record = { vrdex_media_review_get: "assets:review:read", vrdex_media_review_preview: "assets:review:read", vrdex_media_submission_get: "assets:publish", + vrdex_media_contribution_get: "assets:contribute", vrdex_media_submission_preview: "assets:publish", }; const ownedReadToolNames = Object.keys(ownedReadToolScopes); @@ -158,6 +165,7 @@ const hostedOnlyOwnedReadToolNames = new Set([ "vrdex_media_review_get", "vrdex_media_review_preview", "vrdex_media_submission_get", + "vrdex_media_contribution_get", "vrdex_media_submission_preview", ]); const localOwnedReadToolNames = ownedReadToolNames.filter( @@ -176,6 +184,27 @@ export const hostedExpectedToolNames = [ ...writeToolNames, ]; +export function assertHostedContributionSchemas(tool: HostedToolDescriptor) { + const name = String(tool.name); + if (!name.startsWith("vrdex_media_contribution_")) return; + const input = tool.inputSchema as { type?: string; required?: string[]; additionalProperties?: boolean; properties?: Record; oneOf?: Array<{ required?: string[]; additionalProperties?: boolean }> } | undefined; + const output = tool.outputSchema as { required?: string[] } | undefined; + assert.equal(input?.type, "object", `${name} input must be an object.`); + const variants = input?.oneOf ?? [input]; + const required = name === "vrdex_media_contribution_get" ? ["submissionId"] : ["submissionId", "expectedContributionVersion", "idempotencyKey"]; + for (const variant of variants) { + assert.equal(variant?.additionalProperties, false, `${name} input must be strict.`); + for (const field of required) assert.ok(variant?.required?.includes(field), `${name} input lacks ${field}.`); + } + if (name === "vrdex_media_contribution_place") assert.deepEqual(input?.properties?.action?.enum, ["select_primary", "clear_primary"], `${name} placement actions changed.`); + if (name === "vrdex_media_contribution_manage") { + assert.equal(variants.length, 2, `${name} management actions changed.`); + for (const variant of variants) assert.ok(variant?.required?.includes("action"), `${name} action is required.`); + } + const outputFields = name === "vrdex_media_contribution_get" ? ["submissionId", "assetId", "contributionVersion", "metadata", "canSelectPrimary", "canClearPrimary", "canEditMetadata", "canRemove"] : ["operationId", "operationState"]; + for (const field of outputFields) assert.ok(output?.required?.includes(field), `${name} ${name.endsWith("_get") ? "detail" : "receipt"} lacks ${field}.`); +} + export function assertHostedToolSecuritySchemes(tool: HostedToolDescriptor) { assert.equal(typeof tool._meta, "object", `Hosted tool ${String(tool.name)} is missing _meta.`); assert.notEqual(tool._meta, null, `Hosted tool ${String(tool.name)} is missing _meta.`); @@ -1063,6 +1092,7 @@ async function smokeHostedHttp(results: SmokeResult[], options: SmokeOptions) { for (const tool of listedTools ?? []) { assertHostedToolSecuritySchemes(tool); + assertHostedContributionSchemas(tool); } const listedToolNames = new Set((listedTools ?? []).map((tool) => String(tool.name))); diff --git a/tests/scripts/mcp-compat-smoke-cli.test.ts b/tests/scripts/mcp-compat-smoke-cli.test.ts index f61bb01a..fd66a48e 100644 --- a/tests/scripts/mcp-compat-smoke-cli.test.ts +++ b/tests/scripts/mcp-compat-smoke-cli.test.ts @@ -49,7 +49,19 @@ const writeToolScopes: Record = { vrdex_media_review_decide_selected: "assets:review:write", vrdex_media_submission_withdraw: "assets:contribute", vrdex_media_submission_publish: "assets:publish", - vrdex_media_submission_declare: "assets:publish", + vrdex_media_contribution_manage: "assets:contribute", + vrdex_media_contribution_place: "assets:publish", + vrdex_media_contribution_propose_placement: "assets:contribute", +}; +// Independent hosted contract fixture, deliberately not imported from runtime schemas. +const contributionSchemas: Record> = { + vrdex_media_contribution_get: { inputSchema: { type: "object", additionalProperties: false, required: ["submissionId"] }, outputSchema: { required: ["submissionId", "assetId", "contributionVersion", "metadata", "canSelectPrimary", "canClearPrimary", "canEditMetadata", "canRemove"] } }, + vrdex_media_contribution_manage: { inputSchema: { type: "object", oneOf: [ + { additionalProperties: false, required: ["submissionId", "expectedContributionVersion", "idempotencyKey", "action", "metadata"] }, + { additionalProperties: false, required: ["submissionId", "expectedContributionVersion", "idempotencyKey", "action"] }, + ] }, outputSchema: { required: ["operationId", "operationState"] } }, + vrdex_media_contribution_place: { inputSchema: { type: "object", additionalProperties: false, required: ["submissionId", "expectedContributionVersion", "idempotencyKey", "action"], properties: { action: { enum: ["select_primary", "clear_primary"] } } }, outputSchema: { required: ["operationId", "operationState"] } }, + vrdex_media_contribution_propose_placement: { inputSchema: { type: "object", additionalProperties: false, required: ["submissionId", "expectedContributionVersion", "idempotencyKey"] }, outputSchema: { required: ["operationId", "operationState"] } }, }; const expectedWriteTools = Object.keys(writeToolScopes); // Reads, but of the caller's own inventory, so they advertise a scope pair @@ -71,6 +83,7 @@ const ownedReadToolScopes: Record = { vrdex_media_review_get: "assets:review:read", vrdex_media_review_preview: "assets:review:read", vrdex_media_submission_get: "assets:publish", + vrdex_media_contribution_get: "assets:contribute", vrdex_media_submission_preview: "assets:publish", }; const expectedOwnedReadTools = Object.keys(ownedReadToolScopes); @@ -267,6 +280,7 @@ async function startHostedFailureFixture() { // reached the read failures this test is about. tools: [...expectedTools, ...expectedOwnedReadTools, ...expectedWriteTools].map((name) => ({ _meta: { securitySchemes: securitySchemesForTool(name) }, + ...contributionSchemas[name], name, })), }, @@ -413,6 +427,7 @@ async function startHostedSuccessFixture(extraToolName?: string, omittedScope?: result: { tools: [...expectedTools, ...expectedOwnedReadTools, ...expectedWriteTools, ...(extraToolName ? [extraToolName] : [])].map((name) => ({ _meta: { securitySchemes: securitySchemesForTool(name) }, + ...contributionSchemas[name], name, })), }, diff --git a/tests/scripts/mcp-inspector-smoke.test.ts b/tests/scripts/mcp-inspector-smoke.test.ts index 9132ce15..b153049c 100644 --- a/tests/scripts/mcp-inspector-smoke.test.ts +++ b/tests/scripts/mcp-inspector-smoke.test.ts @@ -5,7 +5,7 @@ import { assertExpectedHostedToolNames, assertInspectorDataBackedSearch, } from "../../scripts/smoke-mcp-inspector-client"; -import { assertHostedToolSecuritySchemes, hostedExpectedToolNames } from "../../scripts/smoke-vrdex-mcp-compat"; +import { assertHostedContributionSchemas, assertHostedToolSecuritySchemes, hostedExpectedToolNames } from "../../scripts/smoke-vrdex-mcp-compat"; describe("MCP Inspector smoke harness", () => { const search = { limit: 1, query: "club", type: "all" as const }; @@ -34,10 +34,15 @@ describe("MCP Inspector smoke harness", () => { for (const name of [ "vrdex_contribution_batch_append", "vrdex_media_review_decide", + "vrdex_media_contribution_get", + "vrdex_media_contribution_manage", + "vrdex_media_contribution_place", + "vrdex_media_contribution_propose_placement", "vrdex_event_intake_publish", ]) { assert.ok(hostedExpectedToolNames.includes(name)); } + assert.ok(!hostedExpectedToolNames.includes("vrdex_media_submission_declare")); assert.doesNotThrow(() => { assertExpectedHostedToolNames([...hostedExpectedToolNames].reverse()); }); @@ -54,6 +59,15 @@ describe("MCP Inspector smoke harness", () => { ); }); + it("requires strict contribution command and detail schemas", () => { + const schema = { type: "object", additionalProperties: false, required: ["submissionId", "expectedContributionVersion", "idempotencyKey", "action"], properties: { submissionId: { type: "string" }, expectedContributionVersion: { type: "string" }, idempotencyKey: { type: "string" }, action: { enum: ["select_primary", "clear_primary"] } } }; + const tool = { name: "vrdex_media_contribution_place", inputSchema: schema, outputSchema: { type: "object", required: ["operationId", "operationState"] } }; + assert.doesNotThrow(() => assertHostedContributionSchemas(tool)); + assert.throws(() => assertHostedContributionSchemas({ ...tool, inputSchema: { ...schema, additionalProperties: true } }), /strict/); + assert.throws(() => assertHostedContributionSchemas({ ...tool, outputSchema: {} }), /receipt/); + assert.throws(() => assertHostedContributionSchemas({ ...tool, inputSchema: { ...schema, properties: { ...schema.properties, action: { enum: ["select_primary"] } } } }), /placement/); + }); + it("checks multi-scope contribution tool metadata", () => { assert.doesNotThrow(() => assertHostedToolSecuritySchemes({ name: "vrdex_contribution_batch_append", diff --git a/tests/web/mcp-media-review.test.ts b/tests/web/mcp-media-review.test.ts index 9358996f..60ca809f 100644 --- a/tests/web/mcp-media-review.test.ts +++ b/tests/web/mcp-media-review.test.ts @@ -590,3 +590,27 @@ it("declares evidence and publishes only through separate explicit commands", as }), ); }); + +it("contribution handlers bind verified actor, exact command and strict receipt/detail output", async () => { + const calls: Array<{ name: string; args: Record }> = []; + const receipt = { operationId: "operation-1", operationState: "committed" }; + const projection = { submissionId: "submission-1", assetId: "asset-1", profileId: "profile-1", profileSlug: "fixture", contributionVersion: version, metadata: { label: "Image", credit: "Creator" }, canSelectPrimary: true, canClearPrimary: false, canEditMetadata: true, canRemove: true }; + const handlers = createMcpMediaReviewHandlers(dependencies({ + query: async (name, args) => { calls.push({ name, args }); return projection; }, + mutate: async (name, args) => { calls.push({ name, args }); return receipt; }, + })); + const base = { submissionId: "submission-1", expectedContributionVersion: version, idempotencyKey: "same-key" }; + assert.deepEqual((await handlers.contributionGet({ submissionId: "submission-1" })).structuredContent, projection); + for (const [operation, input] of [ + ["contributionManage", { ...base, action: "update_metadata", metadata: { credit: "Creator" } }], + ["contributionPlace", { ...base, action: "select_primary" }], + ["contributionProposePlacement", base], + ] as const) { + assert.deepEqual((await handlers[operation](input)).structuredContent, receipt); + assert.deepEqual(calls.at(-1), { name: operation, args: { actorUserId: "user-1", emailVerified: true, emailVerificationAttestedAt: 2000, ...input } }); + await assert.rejects(() => handlers[operation]({ ...input, actorUserId: "forged" })); + } + const invalid = createMcpMediaReviewHandlers(dependencies({ query: async () => ({ ...projection, reviewerUserId: "private" }), mutate: async () => ({ ...receipt, privateOwnerAsset: "private" }) })); + await assert.rejects(() => invalid.contributionGet({ submissionId: "submission-1" })); + await assert.rejects(() => invalid.contributionPlace({ ...base, action: "clear_primary" })); +}); diff --git a/tests/web/vrdex-mcp.test.ts b/tests/web/vrdex-mcp.test.ts index 9e8c6d00..2d0712df 100644 --- a/tests/web/vrdex-mcp.test.ts +++ b/tests/web/vrdex-mcp.test.ts @@ -250,6 +250,7 @@ function assertAuthenticatedReadSecuritySchemes(value: unknown) { function isWriteToolName(name: string | undefined) { if (name?.startsWith("vrdex_event_intake_") && name !== "vrdex_event_intake_draft_get" && name !== "vrdex_event_intake_event_get") return true; + if (name?.startsWith("vrdex_media_contribution_") && name !== "vrdex_media_contribution_get") return true; if (name === "vrdex_media_submission_publish" || name === "vrdex_media_submission_declare") return true; if (name === "vrdex_media_upload_begin" || name === "vrdex_media_upload_complete") return true; return ( @@ -263,6 +264,7 @@ function isWriteToolName(name: string | undefined) { // than the public-read schemes every other read tool advertises. function isOwnedReadToolName(name: string | undefined) { if (name === "vrdex_event_intake_draft_get" || name === "vrdex_event_intake_event_get") return true; + if (name === "vrdex_media_contribution_get") return true; if (name === "vrdex_media_submission_get" || name === "vrdex_media_submission_preview") return true; return ( name === "vrdex_get_my_media_submission" || @@ -351,10 +353,13 @@ describe("VRDex MCP server", () => { `); const tools=jsonBodyFromProbe(output).result?.tools??[]; const expected = { + vrdex_media_contribution_get: ["mcp:read", "assets:contribute"], + vrdex_media_contribution_manage: ["mcp:write", "assets:contribute"], + vrdex_media_contribution_place: ["mcp:write", "assets:publish"], + vrdex_media_contribution_propose_placement: ["mcp:write", "assets:contribute"], vrdex_media_submission_get: ["mcp:read", "assets:publish"], vrdex_media_submission_preview: ["mcp:read", "assets:publish"], vrdex_media_submission_publish: ["mcp:write", "assets:publish"], - vrdex_media_submission_declare: ["mcp:write", "assets:publish"], vrdex_media_review_list: ["mcp:read", "assets:review:read"], vrdex_media_review_get: ["mcp:read", "assets:review:read"], vrdex_media_review_preview: ["mcp:read", "assets:review:read"], @@ -749,7 +754,9 @@ describe("VRDex MCP server", () => { "vrdex_media_upload_begin", "vrdex_media_upload_complete", "vrdex_media_submission_publish", - "vrdex_media_submission_declare", + "vrdex_media_contribution_manage", + "vrdex_media_contribution_place", + "vrdex_media_contribution_propose_placement", "vrdex_media_review_decide", "vrdex_media_review_rebase", "vrdex_media_review_decide_selected", @@ -778,7 +785,9 @@ describe("VRDex MCP server", () => { vrdex_media_review_decide_selected: "assets:review:write", vrdex_media_submission_withdraw: "assets:contribute", vrdex_media_submission_publish: "assets:publish", - vrdex_media_submission_declare: "assets:publish", + vrdex_media_contribution_manage: "assets:contribute", + vrdex_media_contribution_place: "assets:publish", + vrdex_media_contribution_propose_placement: "assets:contribute", }; for (const tool of writeTools) { @@ -3393,3 +3402,89 @@ it("serializes typed event roster links and playable slots through hosted MCP", assert.match(output, /"streamId":"alpha"/); assert.match(output, /"outboundLinks":\[/); }); + +it("contribution protocol tools dispatch exact attested wrappers, reject missing scopes and hide legacy declaration", () => { + const output = runMcpProbe(` + import assert from "node:assert/strict"; + import { getFunctionName } from "convex/server"; + import { assertHostedContributionSchemas } from "./scripts/smoke-vrdex-mcp-compat.ts"; + import { createVrdexMcpHandler } from "./apps/web/src/lib/server/vrdex-mcp.ts"; + const calls=[]; const receipt={operationId:"key",operationState:"committed"}; + const detail={submissionId:"submission",assetId:"asset",profileId:"profile",profileSlug:"fixture",contributionVersion:"version",metadata:{label:"Image",credit:"Creator"},canSelectPrimary:true,canClearPrimary:false,canEditMetadata:true,canRemove:true}; + const handler=createVrdexMcpHandler({now:()=>2000,verifyContributorEmail:async()=>true,adminConvex:{ + query:async(ref,args)=>{calls.push({name:getFunctionName(ref),args});return detail}, + mutation:async(ref,args)=>{const name=getFunctionName(ref);if(name==="mcpToolEvents:recordWriteInvocation")return null;calls.push({name,args});return name.includes("declarePublicationEvidence")?{operationId:"key",operationState:"refused",code:"legacy_declaration_retired"}:receipt}, + }}); + const auth=scopes=>({token:"test",clientId:"client",scopes,resource:new URL("https://app.example.test/mcp"),extra:{subjectType:"user",userId:"user",tokenId:"token",requestId:"request"}}); + async function request(method,params,scopes){const response=await handler.fetch(new Request("https://app.example.test/mcp",{method:"POST",headers:{accept:"application/json, text/event-stream","content-type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:1,method,params})}),{authInfo:auth(scopes)});const body=await response.text();return JSON.parse(body.split(/\\r?\\n/).find(line=>line.startsWith("data: "))?.slice(6)??body).result;} + const tools=(await request("tools/list",{},["mcp:read"])).tools; + assert.equal(tools.some(tool=>tool.name==="vrdex_media_submission_declare"),false); + for(const tool of tools)assertHostedContributionSchemas(tool); + const base={submissionId:"submission",expectedContributionVersion:"version",idempotencyKey:"key"}; + const operations=[ + ["vrdex_media_contribution_get","contributionDetailForMcpActor",{submissionId:"submission"},"mcp:read","assets:contribute",detail], + ["vrdex_media_contribution_manage","manageContributionForMcpActor",{...base,action:"update_metadata",metadata:{credit:"Creator"}},"mcp:write","assets:contribute",receipt], + ["vrdex_media_contribution_place","placeContributionForMcpActor",{...base,action:"select_primary"},"mcp:write","assets:publish",receipt], + ["vrdex_media_contribution_propose_placement","proposePlacementForMcpActor",base,"mcp:write","assets:contribute",receipt], + ["vrdex_media_submission_publish","publishForMcpActor",{submissionId:"submission",expectedReviewVersion:"review",idempotencyKey:"key"},"mcp:write","assets:publish",receipt], + ]; + for(const [name,wrapper,input,mcp,scope,expected] of operations){ + const descriptor=tools.find(tool=>tool.name===name);assert.ok(descriptor);assert.ok(descriptor.outputSchema); + assert.deepEqual(descriptor._meta.securitySchemes,[{scopes:[mcp,scope],type:"oauth2"}]); + for(const scopes of [[mcp],[scope]]){const count=calls.length;const result=await request("tools/call",{name,arguments:input},scopes);assert.equal(result.isError,true);assert.equal(calls.length,count);} + const result=await request("tools/call",{name,arguments:input},[mcp,scope]);assert.deepEqual(result.structuredContent,expected); + assert.deepEqual(calls.at(-1),{name:"profileMediaSubmissions:"+wrapper,args:{actorUserId:"user",emailVerified:true,emailVerificationAttestedAt:2000,...input,oauthTokenId:"token",oauthClientId:"client",oauthResource:"https://app.example.test/mcp"}}); + } + for(const action of ["select_primary","clear_primary"]){const count=calls.length;const result=await request("tools/call",{name:"vrdex_media_contribution_place",arguments:{...base,action}},["mcp:write","assets:contribute"]);assert.equal(result.isError,true);assert.equal(calls.length,count);} + const legacyInput={submissionId:"submission",expectedReviewVersion:"review",idempotencyKey:"key",identityConfirmed:true,attributionConfirmed:true,publicationPermitted:true,noKnownRestrictions:true}; + const legacy=await request("tools/call",{name:"vrdex_media_submission_declare",arguments:legacyInput},["mcp:write","assets:publish"]); + assert.equal(legacy.structuredContent.code,"legacy_declaration_retired");assert.equal(calls.at(-1).name,"profileMediaSubmissions:declarePublicationEvidenceForMcpActor"); + await handler.close();console.log("contribution protocol parity passed"); + `); + assert.match(output, /contribution protocol parity passed/); +}); + +it("contribution transport uses actual backend privacy, grant separation and terminal declaration", () => { + const output=runMcpProbe(` + import assert from "node:assert/strict"; + import {convexTest} from "convex-test"; + import {api,internal} from "./convex/_generated/api.js"; + import {schema,modules,seed,createAndUpload} from "./tests/backend/_mediaReviewFixture.ts"; + import {createVrdexMcpHandler} from "./apps/web/src/lib/server/vrdex-mcp.ts"; + process.env.VRDEX_PROFILE_MEDIA_SUBMISSIONS_ENABLED="true";process.env.VRDEX_PROFILE_MEDIA_DIRECT_UPLOAD_ENABLED="true";process.env.VRDEX_PROFILE_MEDIA_KIT_ENABLED="true"; + const t=convexTest({schema,modules}),s=await seed(t),{intent}=await createAndUpload(t,s); + await t.run(async ctx=>{ + await ctx.db.insert("accountFeatureGrants",{userId:s.contributorUserId,feature:"trusted_publisher",state:"active",grantedBy:{tokenIdentifier:"test:operator",issuer:"test",subject:"operator"},grantedAt:Date.now(),updatedAt:Date.now()}); + for(const [tokenId,scopes] of [["publisher",["mcp:read","mcp:write","assets:publish","assets:contribute"]],["contributor",["mcp:read","mcp:write","assets:contribute"]]])await ctx.db.insert("oauthAccessTokens",{tokenId,clientId:"client",userId:s.contributorUserId,subjectType:"user",resource:"https://app.example.test/mcp",scopes,status:"active",issuedAt:Date.now(),expiresAt:Date.now()+600000}); + }); + const handler=createVrdexMcpHandler({verifyContributorEmail:async()=>true,adminConvex:{query:(ref,args)=>t.query(ref,args),mutation:(ref,args)=>t.mutation(ref,args)}}); + async function call(name,args,publisher=false){const scopes=publisher?["mcp:read","mcp:write","assets:publish","assets:contribute"]:["mcp:read","mcp:write","assets:contribute"];const authInfo={token:"test",clientId:"client",scopes,resource:new URL("https://app.example.test/mcp"),extra:{subjectType:"user",userId:s.contributorUserId,tokenId:publisher?"publisher":"contributor",requestId:"request"}};const response=await handler.fetch(new Request("https://app.example.test/mcp",{method:"POST",headers:{accept:"application/json, text/event-stream","content-type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name,arguments:args}})}),{authInfo});const body=await response.text();return JSON.parse(body.split(/\\r?\\n/).find(line=>line.startsWith("data: "))?.slice(6)??body).result;} + const pending=await t.withIdentity(s.contributorIdentity).query(api.profileMediaSubmissions.publisherDetail,{submissionId:intent.submissionId}); + const pubArgs={submissionId:intent.submissionId,expectedReviewVersion:pending.reviewVersion,idempotencyKey:"publish"}; + const published=await call("vrdex_media_submission_publish",pubArgs,true);assert.equal(published.structuredContent.operationState,"committed"); + await t.run(ctx=>ctx.db.patch(intent.submissionId,{privateReason:"private reviewer reason",reviewer:{tokenIdentifier:"private reviewer identity",issuer:"test",subject:"reviewer"}})); + const detail=(await call("vrdex_media_contribution_get",{submissionId:intent.submissionId})).structuredContent;assert.ok(detail);assert.equal(detail.canSelectPrimary,true);assert.doesNotMatch(JSON.stringify(detail),/private reviewer|reviewerTokenIdentifier|privateReason|storageKey|ownerAssets/); + const base={submissionId:intent.submissionId,expectedContributionVersion:detail.contributionVersion,idempotencyKey:"place"}; + for(const action of ["select_primary","clear_primary"]){const denied=await call("vrdex_media_contribution_place",{...base,action});assert.equal(denied.isError,true);} + assert.equal((await t.run(ctx=>ctx.db.query("profileAssetPlacements").filter(q=>q.eq(q.field("placement"),"profile_image")).collect())).length,0); + const legacy=await call("vrdex_media_submission_declare",{...pubArgs,idempotencyKey:"legacy",identityConfirmed:true,attributionConfirmed:true,publicationPermitted:true,noKnownRestrictions:true},true);assert.equal(legacy.structuredContent.code,"declaration_retired");assert.equal(legacy.structuredContent.operationState,"refused"); + await handler.close();console.log("actual contribution privacy and scopes passed"); + `); + assert.match(output,/actual contribution privacy and scopes passed/); +}); + +it("filtered catalog preserves every descriptor emitted by the installed SDK", () => { + const output=runMcpProbe(` + import assert from "node:assert/strict"; + import {McpServer,createMcpHandler} from "./apps/web/node_modules/@modelcontextprotocol/server/dist/index.mjs"; + import {buildVrdexMcpServer,createVrdexMcpHandler} from "./apps/web/src/lib/server/vrdex-mcp.ts"; + const app=buildVrdexMcpServer(),sdk=new McpServer({name:"parity",version:"1"}); + // Use the SDK's original catalog generator as the independent transport oracle. + for(const [name,tool] of Object.entries(app._registeredTools))sdk.registerTool(name,{title:tool.title,description:tool.description,inputSchema:tool.inputSchema,outputSchema:tool.outputSchema,annotations:tool.annotations,icons:tool.icons,execution:tool.execution,_meta:tool._meta},async()=>({content:[]})); + const reference=createMcpHandler(()=>sdk,{legacy:"stateless"}),actual=createVrdexMcpHandler(); + async function list(handler){const response=await handler.fetch(new Request("https://app.example.test/mcp",{method:"POST",headers:{accept:"application/json, text/event-stream","content-type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/list",params:{}})}));const body=await response.text();return JSON.parse(body.split(/\\r?\\n/).find(line=>line.startsWith("data: "))?.slice(6)??body).result.tools;} + assert.deepEqual(await list(actual),(await list(reference)).filter(tool=>tool.name!=="vrdex_media_submission_declare")); + await actual.close();await reference.close();await app.close();console.log("SDK descriptor parity passed"); + `); + assert.match(output,/SDK descriptor parity passed/); +}); From a7df1b269c402058412027b1e78160387ab1e769 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 5 Oct 2026 19:38:27 -0400 Subject: [PATCH 11/28] fix: describe media kit MCP actions --- apps/web/src/lib/server/vrdex-mcp.ts | 15 ++++++++------- tests/web/vrdex-mcp.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/server/vrdex-mcp.ts b/apps/web/src/lib/server/vrdex-mcp.ts index d2575b33..7fb9e321 100644 --- a/apps/web/src/lib/server/vrdex-mcp.ts +++ b/apps/web/src/lib/server/vrdex-mcp.ts @@ -2750,7 +2750,7 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }); } registerTool("vrdex_media_contribution_get", { - title: "Contribution", description: "vrdex_media_contribution_get", + title: "Contribution", description: "Inspect your published media-kit contribution and available actions.", inputSchema: mediaReviewGetInputSchema, outputSchema: mcpOutputSchema(publishedContributionDetailSchema), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _meta: { securitySchemes: mcpOwnedReadSecuritySchemes("vrdex_media_contribution_get") }, @@ -2761,7 +2761,8 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { for (const operation of ["publish", "declare"] as const) { const toolName = operation === "publish" ? "vrdex_media_submission_publish" : "vrdex_media_submission_declare"; registerTool(toolName, { - title: operation === "publish" ? "Publish" : "Confirm evidence", description: toolName, + title: operation === "publish" ? "Publish" : "Confirm evidence", + description: operation === "publish" ? "Publish your contribution to the media kit." : toolName, inputSchema: operation === "publish" ? mediaPublicationSchema : publicationEvidenceSchema, outputSchema: mcpOutputSchema(commandReceiptSchema), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, @@ -2773,13 +2774,13 @@ export function buildVrdexMcpServer(options: VrdexMcpServerOptions = {}) { }); } - for (const [toolName, operation, inputSchema, title] of [ - ["vrdex_media_contribution_manage", "contributionManage", contributionManageCommandSchema, "Manage contribution"], - ["vrdex_media_contribution_place", "contributionPlace", contributionPlacementCommandSchema, "Profile picture"], - ["vrdex_media_contribution_propose_placement", "contributionProposePlacement", contributionCommandBaseSchema, "Request replacement"], + for (const [toolName, operation, inputSchema, title, description] of [ + ["vrdex_media_contribution_manage", "contributionManage", contributionManageCommandSchema, "Manage contribution", "Correct metadata or remove your own kit-only contribution."], + ["vrdex_media_contribution_place", "contributionPlace", contributionPlacementCommandSchema, "Profile picture", "Select your contribution as the profile picture, or undo your unchanged selection."], + ["vrdex_media_contribution_propose_placement", "contributionProposePlacement", contributionCommandBaseSchema, "Request replacement", "Request independent review to replace the profile picture with your published contribution."], ] as const) { registerTool(toolName, { - title, description: toolName, inputSchema, outputSchema: mcpOutputSchema(commandReceiptSchema), + title, description, inputSchema, outputSchema: mcpOutputSchema(commandReceiptSchema), annotations: { readOnlyHint: false, destructiveHint: operation === "contributionManage", idempotentHint: true, openWorldHint: false }, _meta: { securitySchemes: mcpWriteSecuritySchemes(toolName) }, }, async (input: unknown) => { diff --git a/tests/web/vrdex-mcp.test.ts b/tests/web/vrdex-mcp.test.ts index 2d0712df..be9a8e2f 100644 --- a/tests/web/vrdex-mcp.test.ts +++ b/tests/web/vrdex-mcp.test.ts @@ -3420,6 +3420,15 @@ it("contribution protocol tools dispatch exact attested wrappers, reject missing const tools=(await request("tools/list",{},["mcp:read"])).tools; assert.equal(tools.some(tool=>tool.name==="vrdex_media_submission_declare"),false); for(const tool of tools)assertHostedContributionSchemas(tool); + const descriptions={ + vrdex_media_submission_publish:"Publish your contribution to the media kit.", + vrdex_media_contribution_get:"Inspect your published media-kit contribution and available actions.", + vrdex_media_contribution_manage:"Correct metadata or remove your own kit-only contribution.", + vrdex_media_contribution_place:"Select your contribution as the profile picture, or undo your unchanged selection.", + vrdex_media_contribution_propose_placement:"Request independent review to replace the profile picture with your published contribution.", + }; + for(const [name,description] of Object.entries(descriptions))assert.equal(tools.find(tool=>tool.name===name)?.description,description,name); + const base={submissionId:"submission",expectedContributionVersion:"version",idempotencyKey:"key"}; const operations=[ ["vrdex_media_contribution_get","contributionDetailForMcpActor",{submissionId:"submission"},"mcp:read","assets:contribute",detail], From 9d2e47e9c6d8fedb0d3821aa21ba2b9abc0bf44a Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 5 Oct 2026 20:12:22 -0400 Subject: [PATCH 12/28] feat: simplify media kit publication and picture actions --- apps/web/e2e/media-contribution.flow.spec.ts | 114 ++++++-- apps/web/e2e/media-kit.visual.spec.ts | 9 +- .../e2e/trusted-publication.visual.spec.ts | 275 ++++++++---------- .../profile-media-contribution-editor.tsx | 2 +- .../app/_components/profile-public-page.tsx | 9 +- .../media-contributions-panel.tsx | 12 +- .../media-contributions/publication-card.tsx | 218 ++++---------- .../published-contribution-card.tsx | 111 +++++++ .../media-review/media-review-panel.tsx | 11 +- .../account/media-review/media-review-view.ts | 6 +- apps/web/src/app/api/e2e/media/route.ts | 3 + .../media-review-assigned.stories.tsx | 7 +- .../media-review-comparison.stories.tsx | 2 + .../trusted-publication.stories.tsx | 77 ++++- convex/_mediaContributionCommands.ts | 1 + convex/e2eMedia.ts | 67 ++++- convex/profileMediaSubmissions.ts | 9 +- docs/testing/mcp-media-staging-lifecycle.md | 8 + packages/api-contracts/src/media-review.ts | 2 +- .../api-contracts/tests/media-review.test.ts | 2 +- scripts/smoke-vrdex-mcp-compat.ts | 2 +- tests/backend/e2e-media.test.ts | 37 +++ .../media-contribution-commands.test.ts | 34 ++- tests/scripts/mcp-compat-smoke-cli.test.ts | 2 +- tests/web/mcp-media-review.test.ts | 2 +- tests/web/media-command-result.test.ts | 14 +- tests/web/media-review-view.test.ts | 4 + tests/web/vrdex-mcp.test.ts | 2 +- 28 files changed, 659 insertions(+), 383 deletions(-) create mode 100644 apps/web/src/app/account/media-contributions/published-contribution-card.tsx diff --git a/apps/web/e2e/media-contribution.flow.spec.ts b/apps/web/e2e/media-contribution.flow.spec.ts index abe28c93..ca29122f 100644 --- a/apps/web/e2e/media-contribution.flow.spec.ts +++ b/apps/web/e2e/media-contribution.flow.spec.ts @@ -1,3 +1,5 @@ +import sharp from "sharp"; +import type { PublishedContributionDetail } from "@vrdex/api-contracts"; import { createHash, randomBytes } from "node:crypto"; import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; import { @@ -61,7 +63,7 @@ function expectRefusal(response: Awaited>, text: string) } async function grant(page: Page, request: APIRequestContext, origin: string, runId: string) { - const scopes = ["mcp:read", "assets:review:read", "mcp:write", "assets:contribute", "assets:review:write"]; + const scopes = ["mcp:read", "assets:review:read", "mcp:write", "assets:contribute", "assets:publish", "assets:review:write"]; const redirectUri = `${origin}/oauth/e2e-callback`; const response = await page.request.post("/api/developer/oauth-apps", { headers: { origin }, @@ -342,11 +344,55 @@ test("contributor A submits and different owner B reviews media @media-lifecycle // Let the normal creation cooldown expire before the second proposal. // The refused key is durable and must not be reused for this request. await new Promise((resolve) => setTimeout(resolve, 31_000)); + const grants = await request.post("/api/e2e/media", { headers, data: { op: "grant-publication-actors", runId, profileId } }); + expect(grants.status()).toBe(200); + const { batchId } = await grants.json() as { batchId: string }; + const publisherImage = await sharp({ create: { width: 64, height: 48, channels: 3, background: "#173e35" } }).png().toBuffer(); + const publisherKey = `${runId}-publisher-upload`; + const publisherTarget = await call(request, authA.access_token, "vrdex_media_upload_begin", { + mode: "contributor", profileId, expectedUpdatedAt: before.updatedAt, placement: "gallery", contentType: "image/png", byteLength: publisherImage.length, + sha256: createHash("sha256").update(publisherImage).digest("hex"), credit: input.credit, sourceDescription: "Synthetic publisher image", idempotencyKey: publisherKey, + }); + fixture.expiringTransfer = { ...publisherTarget.transfer, image: publisherImage }; + fixture.expectedReservations = 2; + const publisherTransfer = await request.post(publisherTarget.transfer.url, { multipart: { ...publisherTarget.transfer.fields, [publisherTarget.transfer.fileField]: { name: "publisher.png", mimeType: "image/png", buffer: publisherImage } } }); + expect([201, 204]).toContain(publisherTransfer.status()); + const publisherCompletion = await call(request, authA.access_token, "vrdex_media_upload_complete", { intentId: publisherTarget.intentId, idempotencyKey: publisherKey }); + expect(publisherCompletion.operationState).toBe("committed"); + const publishedSubmissionId = publisherCompletion.resourceId!; + await pageA.goto("/account/media-contributions"); + const publisherCard = pageA.locator("section").filter({ has: pageA.locator(`img[src^="/api/account/media-contributions/submissions/${publishedSubmissionId}/file"]`) }); + await expect(publisherCard.getByRole("checkbox")).toHaveCount(0); + await expect(publisherCard.getByRole("button", { name: "Confirm evidence" })).toHaveCount(0); + await publisherCard.getByRole("button", { name: "Publish", exact: true }).click(); + await expect.poll(async () => (await call<{ submissions: Submission[] }>(request, authA.access_token, "vrdex_list_my_media_submissions", {})).submissions.find(row => row.submissionId === publishedSubmissionId)?.status).toBe("approved"); + let publishedDetail = await call(request, authA.access_token, "vrdex_media_contribution_get", { submissionId: publishedSubmissionId }); + const publishedFile = `/api/v0/profiles/${profile.slug}/assets/${publishedDetail.assetId}/file`; + const publishedCard = pageA.locator("section").filter({ has: pageA.locator(`img[src="${publishedFile}"]`) }); + const afterPublish = await call<{ avatarImageUrl?: string }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); + expect(afterPublish.avatarImageUrl).toBe(before.avatarImageUrl); + await pageB.goto(`/${profile.slug}`); + await expect(pageB.getByRole("heading", { name: "Media kit" })).toBeVisible(); + await expect(pageB.locator(`img[src="${publishedFile}"]`)).toBeVisible(); + await publishedCard.getByRole("button", { name: "Select picture" }).click(); + await expect(publishedCard.getByRole("button", { name: "Remove", exact: true })).toHaveCount(0); + await publishedCard.getByRole("button", { name: "Clear picture" }).click(); + await publishedCard.getByRole("button", { name: "Edit metadata" }).click(); + await publishedCard.getByLabel("Title", { exact: true }).fill("Synthetic publisher title"); + await publishedCard.getByRole("button", { name: "Save", exact: true }).click(); + await expect(publishedCard.getByRole("heading", { name: "Synthetic publisher title" })).toBeVisible(); + await publishedCard.getByRole("button", { name: "Select picture" }).click(); + publishedDetail = await call(request, authA.access_token, "vrdex_media_contribution_get", { submissionId: publishedSubmissionId }); + expect(publishedDetail.canClearPrimary).toBe(true); + expect(publishedDetail.canRemove).toBe(false); + stages.push("trusted website kit publication without declarations or hero change, selection undo and metadata correction"); + await new Promise(resolve => setTimeout(resolve, 31_000)); + const refreshed = await call<{ updatedAt: number }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); const image = await source.body(); const directKey = `${runId}-direct`; const target = await call(request, authA.access_token, "vrdex_media_upload_begin", { - mode: "contributor", profileId, expectedUpdatedAt: before.updatedAt, - placement: "profile_image", contentType: "image/png", byteLength: image.byteLength, + mode: "contributor", profileId, expectedUpdatedAt: refreshed.updatedAt, + placement: "gallery", contentType: "image/png", byteLength: image.byteLength, sha256: createHash("sha256").update(image).digest("hex"), credit: input.credit, sourceDescription: input.altText, idempotencyKey: directKey, }); @@ -376,18 +422,13 @@ test("contributor A submits and different owner B reviews media @media-lifecycle expect(submitted.submission.submissionId).not.toBe(rejectedCandidate.submission.submissionId); const directState = await request.post("/api/e2e/media", { headers, data: { op: "inspect", runId, profileId } }); expect(directState.status()).toBe(200); - expect((await directState.json()).counts.reservations).toBe(2); - fixture.expectedReservations = 2; + expect((await directState.json()).counts.reservations).toBe(3); + fixture.expectedReservations = 3; stages.push("minted direct S3 multipart transfer, completion replay and private contributor readback"); + expect((await request.post("/api/e2e/media", { headers, data: { op: "grant-publication-actors", runId, profileId } })).status()).toBe(200); - const assign = await request.post("/api/e2e/media", { headers, data: { op: "assign-review-owner", runId, profileId, reviewerEmail: b.email } }); - expect(assign.status(), "Assign only this synthetic profile to B").toBe(200); - const claimedProfile = await call<{ updatedAt: number }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); - const claimed = await rpc(request, authA.access_token, "vrdex_profile_media_submit", { - ...input, expectedUpdatedAt: claimedProfile.updatedAt, idempotencyKey: `${runId}-claimed`, - }); - expectRefusal(claimed, "The public profile is claimed, so its owner manages profile media."); await pageB.goto("/account/media-review"); + await pageB.getByRole("combobox", { name: "Collection", exact: true }).selectOption(batchId); const rejectFile = `/api/account/media-review/submissions/${rejectedCandidate.submission.submissionId}/file`; const rejectCard = pageB.locator("section").filter({ has: pageB.locator(`img[src="${rejectFile}"]`) }); const privateRejection = `Private fixture rejection ${runId}`; @@ -403,9 +444,9 @@ test("contributor A submits and different owner B reviews media @media-lifecycle }).toEqual({ status: "rejected", disposition, asset: undefined }); const rejectedState = await request.post("/api/e2e/media", { headers, data: { op: "inspect", runId, profileId } }); expect(rejectedState.status()).toBe(200); - expect((await rejectedState.json()).assets).toEqual([]); + expect((await rejectedState.json()).assets).toHaveLength(1); const stillUnpublished = await call<{ avatarImageUrl?: string }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); - expect(stillUnpublished.avatarImageUrl).toBe(before.avatarImageUrl); + expect(stillUnpublished.avatarImageUrl).toContain(publishedDetail.assetId); for (const privateValue of [privateRejection, disposition, sourceUrl, rejectionSourceUrl]) expect(JSON.stringify(stillUnpublished)).not.toContain(privateValue); const reviewerHistory = await call<{ submissions: Submission[] }>(request, authB.access_token, "vrdex_list_my_media_submissions", {}); @@ -466,18 +507,55 @@ test("contributor A submits and different owner B reviews media @media-lifecycle }).toBe("approved"); const approved = await request.post("/api/e2e/media", { headers, data: { op: "inspect", runId, profileId } }); const approvedState = await approved.json() as { assets: { id: string; source: string; state: string }[] }; - expect(approvedState.assets).toHaveLength(1); + expect(approvedState.assets).toHaveLength(2); expect(approvedState.assets[0].source).toBe("community_submitted"); const published = await call<{ avatarImageUrl?: string }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); - expect(published.avatarImageUrl).toBeTruthy(); + expect(published.avatarImageUrl).toBe(stillUnpublished.avatarImageUrl); expect((await request.get(published.avatarImageUrl!)).ok()).toBe(true); await pageB.goto("/account/media-review"); + await pageB.getByRole("combobox", { name: "Collection", exact: true }).selectOption(batchId); await pageB.getByRole("combobox", { name: "Status", exact: true }).selectOption("approved"); const approvedCard = pageB.locator("section").filter({ has: pageB.locator(`img[src="${privateFile}"]`) }); - await expect(approvedCard.locator(`img[src="${published.avatarImageUrl}"]`)).toBeVisible(); + await expect(approvedCard.getByRole("img")).toHaveCount(1); await pageB.goto(`/${profile.slug}`); await expect(pageB.locator(`img[src="${published.avatarImageUrl}"]`).first()).toBeVisible(); - stages.push("donor authorization refusal, replay-safe MCP rebase and approval, native preview and authenticated browser/public readback"); + stages.push("independent approval publishes into kit with unchanged primary picture"); + const kitDetail = await call(request, authA.access_token, "vrdex_media_contribution_get", { submissionId: submitted.submission.submissionId }); + expect(kitDetail.canProposePlacement).toBe(true); + await pageA.goto("/account/media-contributions"); + const kitFile = `/api/v0/profiles/${profile.slug}/assets/${kitDetail.assetId}/file`; + const kitCard = pageA.locator("section").filter({ has: pageA.locator(`img[src="${kitFile}"]`) }); + await expect(kitCard.getByRole("button", { name: "Select picture" })).toHaveCount(0); + await kitCard.getByRole("button", { name: "Request replacement" }).click(); + await expect(kitCard.getByText("Submitted", { exact: true })).toBeVisible(); + expect((await request.post("/api/e2e/media", { headers, data: { op: "grant-publication-actors", runId, profileId } })).status()).toBe(200); + const queue = await call<{ page: { submissionId: string; requestKind?: string }[] }>(request, authB.access_token, "vrdex_media_review_list", { batchId, status: "submitted" }); + const proposal = queue.page.find(row => row.requestKind === "identity_placement"); + expect(proposal).toBeTruthy(); + await pageB.goto("/account/media-review"); + await pageB.getByRole("combobox", { name: "Collection", exact: true }).selectOption(batchId); + const replacementFile = `/api/account/media-review/submissions/${proposal!.submissionId}/file`; + const replacementCard = pageB.locator("section").filter({ has: pageB.locator(`img[src="${replacementFile}"]`) }); + await expect(replacementCard.locator(`img[src="${published.avatarImageUrl}"]`)).toBeVisible(); + await replacementCard.getByLabel("Private review reason", { exact: true }).fill("Synthetic reviewed replacement."); + await replacementCard.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.poll(async () => (await call(request, authA.access_token, "vrdex_media_contribution_get", { submissionId: submitted.submission.submissionId })).canRemove).toBe(false); + const replaced = await call<{ avatarImageUrl?: string }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); + expect(replaced.avatarImageUrl).toContain(kitDetail.assetId); + await pageA.goto("/account/media-contributions"); + await expect(kitCard.getByRole("button", { name: "Remove", exact: true })).toHaveCount(0); + await publishedCard.getByRole("button", { name: "Remove", exact: true }).click(); + await expect(pageA.getByText("Removed", { exact: true })).toBeVisible(); + expect((await request.get(publishedFile)).status()).toBe(404); + stages.push("explicit replacement compares existing picture with published candidate, separate reviewer protects selection, logical removal of unused kit item"); + const assign = await request.post("/api/e2e/media", { headers, data: { op: "assign-review-owner", runId, profileId, reviewerEmail: b.email } }); + expect(assign.status(), "Assign only this synthetic profile to B").toBe(200); + const claimedProfile = await call<{ updatedAt: number }>(request, undefined, "vrdex_get_profile", { slug: profile.slug }); + const claimed = await rpc(request, authA.access_token, "vrdex_profile_media_submit", { + ...input, expectedUpdatedAt: claimedProfile.updatedAt, idempotencyKey: `${runId}-claimed`, + }); + expectRefusal(claimed, "The public profile is claimed, so its owner manages profile media."); + const audit = await request.post("/api/e2e/media", { headers, data: { op: "inspect-audit", runId, profileId } }); expect(audit.status()).toBe(200); diff --git a/apps/web/e2e/media-kit.visual.spec.ts b/apps/web/e2e/media-kit.visual.spec.ts index 20d7c6ba..c74f19d5 100644 --- a/apps/web/e2e/media-kit.visual.spec.ts +++ b/apps/web/e2e/media-kit.visual.spec.ts @@ -767,7 +767,7 @@ test("public profile media kit @visual @fixture", async ({ page }, testInfo) => mediaKit.getByRole("link", { name: "Artwork by Afterglow Studio" }), ).toHaveAttribute("href", "https://example.invalid/afterglow-studio"); await expect( - mediaKit.getByRole("link", { name: "https://example.invalid/aurora-source" }), + mediaKit.getByRole("link", { name: "Open source" }), ).toHaveAttribute("href", "https://example.invalid/aurora-source"); for (const title of [ "Aurora press portrait", @@ -787,5 +787,12 @@ test("public profile media kit @visual @fixture", async ({ page }, testInfo) => await expect(mediaKit.getByRole("link", { name: "Download" }).first()).toBeVisible(); await expect(mediaKit.getByText("PNG / 180 KB", { exact: true })).toBeVisible(); await expect(mediaKit.locator("article")).toHaveCount(4); + await mediaKit.locator("img").evaluateAll(async images => { + await Promise.all(images.map(async image => { + (image as HTMLImageElement).loading = "eager"; + await (image as HTMLImageElement).decode(); + })); + }); await captureRouteScreenshot(page, testInfo, "profile-media-kit"); + await mediaKit.screenshot({ path: `playwright-artifacts/screenshots/${testInfo.project.name}-profile-media-kit-section.png` }); }); diff --git a/apps/web/e2e/trusted-publication.visual.spec.ts b/apps/web/e2e/trusted-publication.visual.spec.ts index d63c9b4a..6e984437 100644 --- a/apps/web/e2e/trusted-publication.visual.spec.ts +++ b/apps/web/e2e/trusted-publication.visual.spec.ts @@ -1,172 +1,127 @@ +import { expect, test } from "@playwright/test"; import sharp from "sharp"; -import { test, expect } from "@playwright/test"; -import { - gotoComponentStory, - prepareStorybookVisualPage, - captureStorybookScreenshot, -} from "./storybook-components"; -test("parent inventory retains publication recovery after reactive approval @storybook-visual", async ({ - page, -}, testInfo) => { +import { gotoComponentStory, prepareStorybookVisualPage, captureStorybookScreenshot } from "./storybook-components"; + +test.beforeEach(async ({ page }) => { await prepareStorybookVisualPage(page); - const candidate = await sharp({ - create: { width: 320, height: 320, channels: 3, background: "#173e35" }, - }) - .png() - .toBuffer(); - await page.route( - "**/api/account/media-contributions/submissions/fixture/file?version=*", - (route) => route.fulfill({ contentType: "image/png", body: candidate }), - ); - await gotoComponentStory( - page, - "account-trusted-publication--reactive-inventory", - ); - await expect( - page.getByRole("img", { name: "Candidate", exact: true }), - ).toHaveJSProperty("naturalWidth", 320); - await page.getByRole("button", { name: "Publish", exact: true }).click(); - await expect(page.getByText("Approved", { exact: true })).toBeVisible(); - await expect( - page.getByText("Outcome unknown", { exact: true }), - ).toBeVisible(); - await expect(page.getByLabel("Identity confirmed")).toBeDisabled(); - for (const name of ["Publish", "Confirm evidence", "Independent review"]) - await expect( - page.getByRole("button", { name, exact: true }), - ).toBeDisabled(); - await expect( - page.getByRole("img", { name: "Candidate", exact: true }), - ).toHaveJSProperty("naturalWidth", 320); - await captureStorybookScreenshot( - page, - testInfo, - "parent-publication-recovery", - ); - await page.getByRole("button", { name: "Retry", exact: true }).click(); - const calls = JSON.parse( - (await page.getByTestId("inventory-commands").textContent())!, - ); - expect(calls).toHaveLength(2); - expect(calls[1]).toEqual(calls[0]); - expect(calls[1].expectedReviewVersion).toBe("v1"); - await expect( - page.getByRole("button", { name: "Retry", exact: true }), - ).toHaveCount(0); - await expect( - page.getByText("Trusted publication", { exact: true }), - ).toBeVisible(); + await page.evaluate(() => sessionStorage.clear()).catch(() => undefined); + const image = await sharp({ create: { width: 320, height: 240, channels: 3, background: "#173e35" } }).png().toBuffer(); + await page.route("**/api/**/file*", route => route.fulfill({ contentType: "image/png", body: image })); }); -test("trusted publication keeps declarations and publish separate @storybook-visual", async ({ - page, -}, testInfo) => { - await prepareStorybookVisualPage(page); - const candidate = await sharp( - Buffer.from( - '', - ), - ) - .png() - .toBuffer(); - await page.route( - "**/api/account/media-contributions/submissions/fixture/file?version=*", - (route) => - route.fulfill({ - contentType: "image/png", - body: candidate, - }), - ); - await gotoComponentStory( - page, - "account-trusted-publication--explicit-commands", - ); - await expect(page.getByTestId("commands")).toHaveText("[]"); - await expect( - page.getByRole("button", { name: "Confirm evidence" }), - ).toBeDisabled(); - for (const label of [ - "Identity confirmed", - "Attribution confirmed", - "Publication permitted", - "No known restrictions", - ]) - await page.getByLabel(label).check(); - await expect(page.getByTestId("commands")).toHaveText("[]"); - await page.getByRole("button", { name: "Confirm evidence" }).click(); - await expect(page.getByText("Confirmed", { exact: true })).toBeVisible(); - await expect(page.getByTestId("commands")).not.toContainText( - 'command\\\":\\\"publish', - ); - await expect( - page.getByRole("button", { name: "Confirm evidence" }), - ).toBeEnabled(); - await captureStorybookScreenshot( - page, - testInfo, - "trusted-publication-evidence", - ); - await page.getByRole("button", { name: "Publish", exact: true }).click(); - await expect( - page.getByText("Independent review required", { exact: true }), - ).toBeVisible(); - expect(await page.getByTestId("commands").textContent()).toContain("v2"); - expect( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - ).toBe(true); - await captureStorybookScreenshot( - page, - testInfo, - "trusted-publication-refusal", - ); - await page - .getByRole("button", { name: "Independent review", exact: true }) - .click(); - await expect( - page.getByRole("button", { name: "Publish", exact: true }), - ).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Inspect", exact: true }), - ).toBeVisible(); + +test("kit publication has one primary action and no declarations @storybook-visual", async ({ page }, testInfo) => { + await gotoComponentStory(page, "account-trusted-publication--explicit-commands"); + await expect(page.getByRole("checkbox")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Confirm evidence" })).toHaveCount(0); + await expect(page.getByText("Media kit", { exact: true })).toBeVisible(); + await expect(page.getByRole("img")).toHaveCount(1); + await page.getByRole("button", { name: "Publish", exact: true }).focus(); + await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeFocused(); + await captureStorybookScreenshot(page, testInfo, "publication-pending"); + await page.keyboard.press("Enter"); + await expect(page.getByText("Independent review required", { exact: true })).toBeVisible(); + expect(JSON.parse((await page.getByTestId("commands").textContent())!)).toHaveLength(1); + await captureStorybookScreenshot(page, testInfo, "publication-refused"); + await page.getByRole("button", { name: "Independent review", exact: true }).click(); + await expect(page.getByRole("button", { name: "Publish", exact: true })).toHaveCount(0); }); -test("publication retains committed command after response loss @storybook-visual", async ({ - page, -}, testInfo) => { - await prepareStorybookVisualPage(page); - const candidate = await sharp({ - create: { width: 320, height: 320, channels: 3, background: "#173e35" }, - }) - .png() - .toBuffer(); - await page.route( - "**/api/account/media-contributions/submissions/fixture/file?version=*", - (route) => route.fulfill({ contentType: "image/png", body: candidate }), - ); +test("publication recovers exact command after reload and lost response @storybook-visual", async ({ page }, testInfo) => { await gotoComponentStory(page, "account-trusted-publication--uncertain"); - await expect( - page.getByRole("img", { name: "Candidate", exact: true }), - ).toHaveJSProperty("naturalWidth", 320); await page.getByRole("button", { name: "Publish", exact: true }).click(); - await expect( - page.getByText("Outcome unknown", { exact: true }), - ).toBeVisible(); - await expect(page.getByLabel("Identity confirmed")).toBeDisabled(); - await expect( - page.getByRole("button", { name: "Publish", exact: true }), - ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "Confirm evidence", exact: true }), - ).toBeDisabled(); - await captureStorybookScreenshot( - page, - testInfo, - "publication-outcome-unknown", - ); + await expect(page.getByText("Outcome unknown", { exact: true })).toBeVisible(); + const first = JSON.parse((await page.getByTestId("commands").textContent())!)[0]; + await captureStorybookScreenshot(page, testInfo, "publication-unknown"); + await page.reload(); + await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeDisabled(); await page.getByRole("button", { name: "Retry", exact: true }).click(); await expect(page.getByText("Published", { exact: true })).toBeVisible(); - const calls = JSON.parse((await page.getByTestId("commands").textContent())!); + const retry = JSON.parse((await page.getByTestId("commands").textContent())!)[0]; + expect(retry).toEqual(first); +}); + +test("parent retains publication recovery when inventory becomes approved @storybook-visual", async ({ page }) => { + await gotoComponentStory(page, "account-trusted-publication--reactive-inventory"); + await page.getByRole("button", { name: "Publish", exact: true }).click(); + await expect(page.getByText("Outcome unknown", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + const calls = JSON.parse((await page.getByTestId("inventory-commands").textContent())!); expect(calls).toHaveLength(2); expect(calls[1]).toEqual(calls[0]); + await expect(page.getByText("Trusted publication", { exact: true })).toBeVisible(); +}); + +test("published actions follow capabilities and retain corrections on conflict @storybook-visual", async ({ page }, testInfo) => { + await gotoComponentStory(page, "account-trusted-publication--published"); + await expect(page.getByRole("button", { name: "Select picture" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove", exact: true })).toBeVisible(); + await captureStorybookScreenshot(page, testInfo, "contribution-published"); + await page.getByRole("button", { name: "Select picture" }).click(); + await expect(page.getByRole("button", { name: "Clear picture" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Clear picture" }).click(); + await page.getByRole("button", { name: "Edit metadata" }).click(); + await page.getByLabel("Title", { exact: true }).fill("Corrected portrait"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Review changed. Inspect the current images before deciding again.", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Title", { exact: true })).toHaveValue("Corrected portrait"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Corrected portrait" })).toBeVisible(); + await page.getByRole("button", { name: "Remove", exact: true }).click(); + await expect(page.getByText("Removed", { exact: true })).toBeVisible(); +}); + +test("protected contribution offers reviewed replacement only @storybook-visual", async ({ page }, testInfo) => { + await gotoComponentStory(page, "account-trusted-publication--protected"); + await expect(page.getByRole("button", { name: "Remove", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Edit metadata" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Select picture" })).toHaveCount(0); + await captureStorybookScreenshot(page, testInfo, "contribution-protected"); + await page.getByRole("button", { name: "Request replacement" }).click(); + await expect(page.getByText("Submitted", { exact: true })).toBeVisible(); + const commands = JSON.parse((await page.getByTestId("published-commands").textContent())!); + expect(commands[0].kind).toBe("propose"); + expect(commands[0].input).not.toHaveProperty("metadata"); +}); + +test("published correction replays exact payload after reload @storybook-visual", async ({ page }, testInfo) => { + await gotoComponentStory(page, "account-trusted-publication--published-uncertain"); + await page.getByRole("button", { name: "Edit metadata" }).click(); + await page.getByLabel("Credit", { exact: true }).fill("New photographer"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Outcome unknown", { exact: true })).toBeVisible(); + const first = JSON.parse((await page.getByTestId("published-commands").textContent())!)[0]; + await captureStorybookScreenshot(page, testInfo, "contribution-unknown"); + await page.reload(); + await expect(page.getByLabel("Credit", { exact: true })).toHaveValue("New photographer"); + await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + const retry = JSON.parse((await page.getByTestId("published-commands").textContent())!)[0]; + expect(retry).toEqual(first); + await expect(page.getByText("Saved", { exact: true })).toBeVisible(); +}); + +test("kit approval previews one image and replacement compares two @storybook-visual", async ({ page }, testInfo) => { + await gotoComponentStory(page, "account-assigned-media-review--local-provenance"); + await expect(page.getByRole("img")).toHaveCount(1); + await expect(page.getByText("Media kit", { exact: true })).toBeVisible(); + await captureStorybookScreenshot(page, testInfo, "review-kit-approval"); + await gotoComponentStory(page, "account-assigned-media-review--published-replacement"); + await expect(page.getByRole("img")).toHaveCount(2); + await expect(page.getByText("Current", { exact: true })).toBeVisible(); + await expect(page.getByText("Candidate", { exact: true })).toBeVisible(); + await captureStorybookScreenshot(page, testInfo, "review-published-replacement"); +}); + + +test("logical removal recovery survives unavailable detail and reload @storybook-visual", async ({ page }) => { + await gotoComponentStory(page, "account-trusted-publication--removed-uncertain"); + await page.getByRole("button", { name: "Remove", exact: true }).click(); + await expect(page.getByText("Outcome unknown", { exact: true })).toBeVisible(); + const first = JSON.parse((await page.getByTestId("removal-commands").textContent())!)[0]; + await expect(page.getByRole("img")).toHaveCount(0); + await page.reload(); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByText("Removed", { exact: true })).toBeVisible(); + expect(JSON.parse((await page.getByTestId("removal-commands").textContent())!)[0]).toEqual(first); }); diff --git a/apps/web/src/app/_components/profile-media-contribution-editor.tsx b/apps/web/src/app/_components/profile-media-contribution-editor.tsx index f150dc37..ba02aa18 100644 --- a/apps/web/src/app/_components/profile-media-contribution-editor.tsx +++ b/apps/web/src/app/_components/profile-media-contribution-editor.tsx @@ -97,7 +97,7 @@ export function ProfileMediaContributionEditor({ const file = prepared.file; const intent = await createUploadIntent({ profileId: profile.id, - requestedPlacement: profile.profileType === "person" ? "profile_image" : "primary_logo", + requestedPlacement: "gallery", originalFileName: file.name, mimeType: profileMediaMimeType(file.type, file.name) ?? file.type, byteSize: file.size, diff --git a/apps/web/src/app/_components/profile-public-page.tsx b/apps/web/src/app/_components/profile-public-page.tsx index 61dd5219..939a93a1 100644 --- a/apps/web/src/app/_components/profile-public-page.tsx +++ b/apps/web/src/app/_components/profile-public-page.tsx @@ -343,6 +343,7 @@ function mimeLabel(value: string): string { } function MediaAssetCard({ asset, label, featured = false }: { asset: PublicProfileAsset; label: string; featured?: boolean }) { + const title = asset.label?.trim() || label; const creditUrl = safeCreditUrl(asset.creditUrl); const downloadMimeType = asset.downloadMimeType ?? asset.mimeType; const downloadByteSize = asset.downloadByteSize ?? asset.byteSize; @@ -350,17 +351,17 @@ function MediaAssetCard({ asset, label, featured = false }: { asset: PublicProfi
-

{asset.label ?? label}

+

{title}

{asset.caption ?

{asset.caption}

: null} {creditUrl ? ( - {asset.credit || creditUrl} + {asset.credit || "Open source"} ) : asset.credit ? (

{asset.credit}

@@ -368,7 +369,7 @@ function MediaAssetCard({ asset, label, featured = false }: { asset: PublicProfi

{mimeLabel(downloadMimeType)} / {formatByteSize(downloadByteSize)}

- + Download
diff --git a/apps/web/src/app/account/media-contributions/media-contributions-panel.tsx b/apps/web/src/app/account/media-contributions/media-contributions-panel.tsx index 9e623e73..39084b82 100644 --- a/apps/web/src/app/account/media-contributions/media-contributions-panel.tsx +++ b/apps/web/src/app/account/media-contributions/media-contributions-panel.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useState } from "react"; import { PublicationCard } from "./publication-card"; +import { PublishedContributionCard } from "./published-contribution-card"; import { useMutation, useQuery, usePaginatedQuery } from "convex/react"; import { api } from "@convex-generated-api"; @@ -10,6 +11,11 @@ import { Button } from "@/components/ui/button"; import { Card, SectionTitle } from "@/components/ui/card"; import { Notice } from "@/components/ui/notice"; +function hasPendingPublication(submissionId: string) { + try { return typeof window !== "undefined" && sessionStorage.getItem(`vrdex:publication:${submissionId}`) !== null; } + catch { return false; } +} + const statusLabel = { upload_pending: "Upload pending", submitted: "Submitted", @@ -83,7 +89,9 @@ export function MediaContributionsPanel() {
Requested use
- {submission.requestedPlacement === "profile_image" + {submission.requestedPlacement === "gallery" + ? "Media kit" + : submission.requestedPlacement === "profile_image" ? "Profile image" : "Primary logo"}
@@ -94,6 +102,7 @@ export function MediaContributionsPanel() {
{pendingPublications.has(submission.submissionId) || + hasPendingPublication(submission.submissionId) || (access?.canPublishMedia && submission.publisherTargetAvailable && ["submitted", "under_review"].includes(submission.status)) ? ( @@ -104,6 +113,7 @@ export function MediaContributionsPanel() { } /> ) : null} + {submission.status === "approved" && submission.requestKind !== "identity_placement" ? : null} {submission.status === "approved" ? (

{submission.publicationMethod === "trusted_publisher" diff --git a/apps/web/src/app/account/media-contributions/publication-card.tsx b/apps/web/src/app/account/media-contributions/publication-card.tsx index c12cf8eb..66efaccf 100644 --- a/apps/web/src/app/account/media-contributions/publication-card.tsx +++ b/apps/web/src/app/account/media-contributions/publication-card.tsx @@ -1,195 +1,79 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { api } from "@convex-generated-api"; import type { Id } from "../../../../../../convex/_generated/dataModel"; -import type { - CommandReceipt, - MediaPublication, - PublicationEvidence, - ReviewDetail, -} from "@vrdex/api-contracts"; +import { mediaPublicationSchema, type CommandReceipt, type MediaPublication, type ReviewDetail } from "@vrdex/api-contracts"; import { Button } from "@/components/ui/button"; import { Notice } from "@/components/ui/notice"; -import { MediaReviewComparison } from "@/components/media-review-comparison"; -import { - reviewDecisionMessage, - reviewPlacementImage, -} from "../media-review/media-review-view"; +import { reviewDecisionMessage } from "../media-review/media-review-view"; -export function PublicationCard({ - submissionId, - onPendingChange, -}: { +export function PublicationCard({ submissionId, onPendingChange }: { submissionId: Id<"profileMediaSubmissions">; onPendingChange?: (pending: boolean) => void; }) { - const detail = useQuery(api.profileMediaSubmissions.publisherDetail, { - submissionId, - }); - const declare = useMutation( - api.profileMediaSubmissions.declarePublicationEvidence, - ); + const detail = useQuery(api.profileMediaSubmissions.publisherDetail, { submissionId }); const publish = useMutation(api.profileMediaSubmissions.publish); - return ( - declare({ ...input, submissionId })} - publish={(input) => publish({ ...input, submissionId })} - onPendingChange={onPendingChange} - /> - ); + return publish({ ...input, submissionId })} onPendingChange={onPendingChange} />; } -const declarations = { - identityConfirmed: "Identity confirmed", - attributionConfirmed: "Attribution confirmed", - publicationPermitted: "Publication permitted", - noKnownRestrictions: "No known restrictions", -} as const; -export function PublicationCardView({ - detail, - declare, - publish, - onPendingChange, -}: { + +export function PublicationCardView({ detail, submissionId = detail?.submissionId, publish, onPendingChange }: { detail: ReviewDetail | null | undefined; - declare: (input: PublicationEvidence) => Promise; + submissionId?: string; publish: (input: MediaPublication) => Promise; onPendingChange?: (pending: boolean) => void; }) { - const [confirmed, setConfirmed] = useState({ - identityConfirmed: false, - attributionConfirmed: false, - publicationPermitted: false, - noKnownRestrictions: false, - }); const [reviewOnly, setReviewOnly] = useState(false); const [busy, setBusy] = useState(false); + const [ready, setReady] = useState(false); const [message, setMessage] = useState(null); - const [pending, setPending] = useState<{ - kind: "declare" | "publish"; - input: PublicationEvidence | MediaPublication; - } | null>(null); - if (!detail) return null; - const current = detail; - async function command(kind: "declare" | "publish") { - setBusy(true); - const input = - pending?.kind === kind - ? pending.input - : { - submissionId: current.submissionId, - expectedReviewVersion: current.reviewVersion, - idempotencyKey: crypto.randomUUID(), - ...(kind === "declare" ? confirmed : {}), - }; - setPending({ kind, input }); + const [pending, setPending] = useState(null); + const storageKey = `vrdex:publication:${submissionId}`; + useEffect(() => { + let recovered: MediaPublication | null = null; + try { + const value = mediaPublicationSchema.safeParse(JSON.parse(sessionStorage.getItem(storageKey) ?? "null")); + if (value.success && value.data.submissionId === submissionId) recovered = value.data; + } catch { /* A new decision is safe only when no command was stored. */ } + queueMicrotask(() => { if (recovered) { setPending(recovered); setMessage("Outcome unknown"); } setReady(true); }); + }, [storageKey, submissionId]); + + async function command() { + if (!ready || busy || (!pending && !detail)) return; + const input = pending ?? { submissionId: detail!.submissionId, expectedReviewVersion: detail!.reviewVersion, idempotencyKey: crypto.randomUUID() }; + // Store before sending, so reload and a lost response replay this exact command. + try { sessionStorage.setItem(storageKey, JSON.stringify(input)); } catch { setMessage("Submission failed"); return; } + setPending(input); onPendingChange?.(true); + setBusy(true); try { - const receipt = - kind === "declare" - ? await declare(input as PublicationEvidence) - : await publish(input); - setMessage( - receipt.operationState === "committed" - ? kind === "declare" - ? "Confirmed" - : "Published" - : receipt.code === "independent_review_required" - ? "Independent review required" - : reviewDecisionMessage(receipt).message, - ); + const receipt = await publish(input); + setMessage(receipt.operationState === "committed" ? "Published" : receipt.code === "independent_review_required" ? "Independent review required" : reviewDecisionMessage(receipt).message); if (receipt.operationState !== "in_progress") { + sessionStorage.removeItem(storageKey); setPending(null); onPendingChange?.(false); } - } catch { - setMessage("Outcome unknown"); - } finally { - setBusy(false); - } + } catch { setMessage("Outcome unknown"); } + finally { setBusy(false); } } - if (reviewOnly) - return ( -

- Independent review - -
- ); - return ( -
- -

- {current.sourceDescription ?? current.sourceUrl} -

-
- {Object.entries(declarations).map(([name, label]) => ( - - ))} -
-
- - - -
- {message ? {message} : null} - {pending ? ( - - ) : null} + if (!detail) return null; + if (reviewOnly) return
Independent review
; + return
+ {detail.candidate.rendition ? ( + // eslint-disable-next-line @next/next/no-img-element + {detail.altText + ) : Unavailable} +
+
Destination
Media kit
+
Credit
{detail.credit}
+
Source
{detail.sourceUrl ? {detail.sourceDescription || "Open source"} : detail.sourceDescription}
+
+
+ +
- ); + {message ? {message} : null} + {pending ? : null} +
; } diff --git a/apps/web/src/app/account/media-contributions/published-contribution-card.tsx b/apps/web/src/app/account/media-contributions/published-contribution-card.tsx new file mode 100644 index 00000000..b0cbe058 --- /dev/null +++ b/apps/web/src/app/account/media-contributions/published-contribution-card.tsx @@ -0,0 +1,111 @@ +"use client"; +import { useEffect, useState, type FormEvent } from "react"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "@convex-generated-api"; +import type { Id } from "../../../../../../convex/_generated/dataModel"; +import { publishedContributionDetailSchema, contributionManageCommandSchema, contributionPlacementCommandSchema, contributionCommandBaseSchema, type PublishedContributionDetail, type CommandReceipt, type ContributionManageCommand, type ContributionPlacementCommand, type ContributionCommandBase } from "@vrdex/api-contracts"; +import { Button } from "@/components/ui/button"; +import { Field, Input, Textarea } from "@/components/ui/field"; +import { Notice } from "@/components/ui/notice"; +import { reviewDecisionMessage } from "../media-review/media-review-view"; + +type Pending = { kind: "manage"; input: ContributionManageCommand } | { kind: "place"; input: ContributionPlacementCommand } | { kind: "propose"; input: ContributionCommandBase }; +export function PublishedContributionCard({ submissionId }: { submissionId: Id<"profileMediaSubmissions"> }) { + const detail = useQuery(api.profileMediaSubmissions.contributionDetail, { submissionId }); + const manage = useMutation(api.profileMediaSubmissions.manageContribution); + const place = useMutation(api.profileMediaSubmissions.placeContribution); + const propose = useMutation(api.profileMediaSubmissions.proposePlacement); + return manage({ ...input, submissionId })} place={input => place({ ...input, submissionId })} propose={input => propose({ ...input, submissionId })} />; +} +export function PublishedContributionCardView({ submissionId, detail, manage, place, propose }: { + submissionId: string; + detail: PublishedContributionDetail | null | undefined; + manage: (input: ContributionManageCommand) => Promise; + place: (input: ContributionPlacementCommand) => Promise; + propose: (input: ContributionCommandBase) => Promise; +}) { + const storageKey = `vrdex:contribution:${submissionId}`; + const draftKey = `${storageKey}:draft`; + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [ready, setReady] = useState(false); + const [message, setMessage] = useState(null); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(null); + const [removed, setRemoved] = useState(false); + useEffect(() => { + try { + const saved = JSON.parse(sessionStorage.getItem(storageKey) ?? "null") as Pending | null; + const parsed = saved?.kind === "manage" ? contributionManageCommandSchema.safeParse(saved.input) : saved?.kind === "place" ? contributionPlacementCommandSchema.safeParse(saved.input) : saved?.kind === "propose" ? contributionCommandBaseSchema.safeParse(saved.input) : null; + const savedDraft = publishedContributionDetailSchema.shape.metadata.safeParse(JSON.parse(sessionStorage.getItem(draftKey) ?? "null")); + queueMicrotask(() => { + if (parsed?.success && parsed.data.submissionId === submissionId) { setPending(saved); setMessage("Outcome unknown"); } + if (savedDraft.success) { setDraft(savedDraft.data); setEditing(true); } + }); + } catch { /* Corrupt local state cannot authorize a command. */ } + queueMicrotask(() => setReady(true)); + }, [storageKey, draftKey, submissionId]); + async function run(command: Pending) { + if (!ready || busy) return; + try { sessionStorage.setItem(storageKey, JSON.stringify(command)); } catch { setMessage("Submission failed"); return; } + setPending(command); + setBusy(true); + try { + const receipt = command.kind === "manage" ? await manage(command.input) : command.kind === "place" ? await place(command.input) : await propose(command.input); + setMessage(receipt.operationState === "committed" ? command.kind === "propose" ? "Submitted" : command.kind === "manage" ? command.input.action === "remove" ? "Removed" : "Saved" : command.input.action === "clear_primary" ? "Cleared" : "Selected" : reviewDecisionMessage(receipt).message); + if (receipt.operationState !== "in_progress") { sessionStorage.removeItem(storageKey); setPending(null); } + if (receipt.operationState === "committed" && command.kind === "manage") { + sessionStorage.removeItem(draftKey); + setDraft(null); + setEditing(false); + if (command.input.action === "remove") setRemoved(true); + } + } catch { setMessage("Outcome unknown"); } + finally { setBusy(false); } + } + function base() { return { submissionId, expectedContributionVersion: detail!.contributionVersion, idempotencyKey: crypto.randomUUID() }; } + function update(name: keyof PublishedContributionDetail["metadata"], value: string) { + const next = { ...(draft ?? detail!.metadata), [name]: value }; + setDraft(next); + try { sessionStorage.setItem(draftKey, JSON.stringify(next)); } catch { setMessage("Submission failed"); } + } + function save(event: FormEvent) { + event.preventDefault(); + if (!detail || !draft || pending || busy || !detail.canEditMetadata) return; + const metadata = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, value?.trim() || null])); + void run({ kind: "manage", input: { ...base(), action: "update_metadata", metadata } }); + } + const locked = !ready || busy || pending !== null; + if (removed) return Removed; + if (detail === undefined) return

Loading…

; + if (!detail && !pending) return null; + return
+ {detail ? <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {detail.metadata.altText +

{detail.metadata.label}

+

{detail.metadata.credit}

+ {detail.metadata.sourceUrl ? {detail.metadata.sourceDescription || "Open source"} :

{detail.metadata.sourceDescription}

} +
+ {detail.canSelectPrimary ? : null} + {detail.canClearPrimary ? : null} + {detail.canProposePlacement ? : null} + {detail.canEditMetadata ? : null} + {detail.canRemove ? : null} +
+ {editing && draft ?
+
+ Title update("label", e.target.value)} /> + Credit update("credit", e.target.value)} /> + Credit URL update("creditUrl", e.target.value)} /> + Source URL update("sourceUrl", e.target.value)} /> + Alt text update("altText", e.target.value)} /> + Source description