From 5ff16400739d09d68423dc2e11110df8b65eaaba Mon Sep 17 00:00:00 2001 From: Yeusepe Date: Thu, 23 Jul 2026 19:07:37 -0500 Subject: [PATCH 01/64] feat(storage): implement package storage and VPM lifecycle --- .config/dotnet-tools.json | 11 + apps/api/src/index.ts | 13 +- apps/api/src/lib/env.test.ts | 4 + apps/api/src/lib/env.ts | 6 + apps/api/src/routes/creatorUploads.test.ts | 113 ++ apps/api/src/routes/creatorUploads.ts | 47 +- apps/api/src/routes/vpm.test.ts | 196 +++- apps/api/src/routes/vpm.ts | 259 +++-- apps/api/src/routes/vpmAliasPackage.test.ts | 96 ++ apps/api/src/routes/vpmAliasPackage.ts | 124 ++ apps/api/src/routes/vpmImporterPackage.ts | 139 +++ .../test/e2e/buyer-delivery-flow.e2e.test.ts | 68 +- apps/api/test/index.test.ts | 2 + apps/api/test/support/buildApp.ts | 4 + .../dashboard/AuthRequiredState.tsx | 17 +- .../dashboard/CertificateWorkspacePanels.tsx | 8 +- .../dashboard/CouplingForensicsPanel.tsx | 84 +- .../components/dashboard/DashboardHeader.tsx | 70 +- .../dashboard/PackageRegistryAccessGate.tsx | 7 +- .../dashboard/PackageRegistryPanel.tsx | 4 +- .../components/dashboard/cards/StatCard.tsx | 33 +- .../dashboard/panels/DangerZonePanel.tsx | 75 +- .../panels/OnboardingProgressPanel.tsx | 29 +- .../dashboard/panels/ServerSettingsPanel.tsx | 69 +- .../dashboard/panels/StatsOverviewPanel.tsx | 12 +- .../panels/StoreIntegrationsPanel.tsx | 31 +- apps/web/src/components/ui/Icon.tsx | 35 +- apps/web/src/components/ui/Select.tsx | 35 +- apps/web/src/components/ui/Toast.tsx | 83 +- apps/web/src/icons/manifest.ts | 67 +- apps/web/src/icons/types.ts | 2 +- .../routes/_authenticated/account.lazy.tsx | 191 +--- .../account/authorized-apps.lazy.tsx | 18 +- .../_authenticated/account/billing.lazy.tsx | 97 +- .../account/connections.lazy.tsx | 19 +- .../_authenticated/account/index.lazy.tsx | 19 +- .../_authenticated/account/licenses.lazy.tsx | 36 +- .../_authenticated/account/machines.lazy.tsx | 10 +- .../routes/_authenticated/dashboard.lazy.tsx | 203 +--- .../dashboard/audit-logs.lazy.tsx | 19 +- .../dashboard/collaboration.lazy.tsx | 124 +- .../dashboard/integrations.lazy.tsx | 119 +- .../dashboard/server-rules.lazy.tsx | 15 +- .../_authenticated/dashboard/setup.lazy.tsx | 17 +- .../_authenticated/verify/purchase.lazy.tsx | 9 +- .../src/routes/access.$catalogProductId.tsx | 31 +- apps/web/src/routes/collab-invite.tsx | 99 +- .../get-in-unity.$creatorRef.$productRef.tsx | 23 +- apps/web/src/routes/install/error.lazy.tsx | 16 +- apps/web/src/routes/install/success.lazy.tsx | 15 +- .../src/routes/oauth/callback/itchio.lazy.tsx | 4 +- apps/web/src/routes/oauth/consent.lazy.tsx | 61 +- apps/web/src/routes/oauth/error.tsx | 15 +- apps/web/src/routes/oauth/login.tsx | 18 +- apps/web/src/routes/setup/discord-role.tsx | 25 +- apps/web/src/routes/setup/jinxxy.lazy.tsx | 71 +- .../src/routes/setup/lemonsqueezy.lazy.tsx | 89 +- apps/web/src/routes/setup/payhip.lazy.tsx | 90 +- apps/web/src/routes/sign-in-redirect.tsx | 16 +- apps/web/src/routes/sign-in.tsx | 33 +- apps/web/src/routes/verify/error.tsx | 16 +- apps/web/src/routes/verify/success.tsx | 5 +- .../dashboard/partials/02-shell-sidebar.css | 97 +- apps/web/src/styles/globals.css | 27 + apps/web/test/unit/icon-consistency.test.ts | 187 +++ apps/web/test/unit/icon.test.tsx | 34 +- bun.lock | 444 ++++++-- bunfig.toml | 1 + convex/packageRegistry.realtest.ts | 123 ++ convex/packageRegistry.ts | 156 ++- ...ndency-security-overrides.contract.test.ts | 30 + ops/dev-supervisor.test.ts | 57 + ops/dev-supervisor.ts | 118 +- ops/docs/ste.test.ts | 117 ++ ops/docs/ste.ts | 457 ++++++++ ops/docs/tsconfig.json | 9 + ops/icons/transform.test.ts | 9 +- ops/icons/transform.ts | 3 +- ops/importer/importVersion.e2e.test.ts | 34 +- ops/importer/vpmBootstrap.e2e.test.ts | 834 ++++++++++++++ ops/infisical/secrets.template.yaml | 1 + ops/ingest-pipeline/accept5gb.e2e.test.ts | 87 +- ops/ingest-tus/ingestTusServer.e2e.test.ts | 34 +- ops/ingest-tus/server.ts | 5 +- ...ode-dependency-resolution.contract.test.ts | 23 + ops/scheduler/server.test.ts | 23 + ops/scheduler/server.ts | 9 +- ops/storage-core/buildPackageCorpus.ts | 14 + .../canonicalizer.security.test.ts | 59 +- ops/storage-core/canonicalizer.ts | 31 +- ops/storage-core/config.test.ts | 43 +- ops/storage-core/config.ts | 59 +- ops/storage-core/desyncCaidx.realtest.ts | 217 ++++ ops/storage-core/desyncCas.ts | 65 +- ops/storage-core/desyncCorpus.realtest.ts | 425 +++++++ .../desyncFileProfiles.realtest.ts | 437 +++++++ ops/storage-core/desyncHttpStore.e2e.test.ts | 170 +++ ops/storage-core/desyncPacking.e2e.test.ts | 165 +++ ops/storage-core/desyncPackingTestSupport.ts | 469 ++++++++ .../fixtures/package-contracts-v2.json | 84 ++ .../generatePackageContractVectors.ts | 17 + ops/storage-core/linuxCodecWorker.py | 167 +++ .../linuxMaterialization.realtest.ts | 648 +++++++++++ ops/storage-core/longtailCorpus.realtest.ts | 235 ++++ .../packageContractGoldenVectors.ts | 306 +++++ ops/storage-core/packageContractsV2.test.ts | 242 ++++ ops/storage-core/packageContractsV2.ts | 1014 +++++++++++++++++ ops/storage-core/packageCorpus.test.ts | 70 ++ ops/storage-core/packageCorpus.ts | 304 +++++ ops/storage-core/packageCorpusEvaluation.ts | 300 +++++ ops/storage-core/process.ts | 82 +- ops/storage-core/s3Cas.e2e.test.ts | 38 +- ops/storage-core/s3Control.ts | 205 +++- ops/storage-core/storageCore.e2e.test.ts | 38 +- .../disposableStorageHarness.e2e.test.ts | 122 ++ ops/testing/disposableStorageHarness.ts | 423 +++++++ ops/testing/unityPackageFixture.ts | 34 +- package.json | 43 +- .../src/yucpAliasPackageContract.test.ts | 7 +- .../shared/src/yucpAliasPackageContract.ts | 2 +- 120 files changed, 10465 insertions(+), 2255 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 apps/api/src/routes/vpmAliasPackage.test.ts create mode 100644 apps/api/src/routes/vpmAliasPackage.ts create mode 100644 apps/api/src/routes/vpmImporterPackage.ts create mode 100644 apps/web/test/unit/icon-consistency.test.ts create mode 100644 ops/docs/ste.test.ts create mode 100644 ops/docs/ste.ts create mode 100644 ops/docs/tsconfig.json create mode 100644 ops/importer/vpmBootstrap.e2e.test.ts create mode 100644 ops/node-dependency-resolution.contract.test.ts create mode 100644 ops/storage-core/buildPackageCorpus.ts create mode 100644 ops/storage-core/desyncCaidx.realtest.ts create mode 100644 ops/storage-core/desyncCorpus.realtest.ts create mode 100644 ops/storage-core/desyncFileProfiles.realtest.ts create mode 100644 ops/storage-core/desyncHttpStore.e2e.test.ts create mode 100644 ops/storage-core/desyncPacking.e2e.test.ts create mode 100644 ops/storage-core/desyncPackingTestSupport.ts create mode 100644 ops/storage-core/fixtures/package-contracts-v2.json create mode 100644 ops/storage-core/generatePackageContractVectors.ts create mode 100644 ops/storage-core/linuxCodecWorker.py create mode 100644 ops/storage-core/linuxMaterialization.realtest.ts create mode 100644 ops/storage-core/longtailCorpus.realtest.ts create mode 100644 ops/storage-core/packageContractGoldenVectors.ts create mode 100644 ops/storage-core/packageContractsV2.test.ts create mode 100644 ops/storage-core/packageContractsV2.ts create mode 100644 ops/storage-core/packageCorpus.test.ts create mode 100644 ops/storage-core/packageCorpus.ts create mode 100644 ops/storage-core/packageCorpusEvaluation.ts create mode 100644 ops/testing/disposableStorageHarness.e2e.test.ts create mode 100644 ops/testing/disposableStorageHarness.ts diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..45f25ca90 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "vrchat.vpm.cli": { + "version": "0.1.28", + "commands": ["vpm"], + "rollForward": false + } + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index de0b3aeac..65c91e03c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -357,9 +357,9 @@ function initializeAuth(webhookBaseUrl?: string) { frontendBaseUrl: frontendUrl, convexApiSecret: env.CONVEX_API_SECRET ?? '', convexUrl, - deliveryBaseUrl: env.DELIVERY_BASE_URL, - deliveryHmacKey: env.DELIVERY_HMAC_KEY, + publicVpmIndexUrl: env.VPM_PUBLIC_INDEX_URL, vpmBaseUrl: env.VPM_BASE_URL, + vpmTokenKey: env.VPM_TOKEN_KEY, }, }); @@ -902,6 +902,15 @@ async function routeRequest(request: Request): Promise { if (pathname === '/api/vpm/repo-token' && vpmRoutes) { return vpmRoutes.mintRepoToken(request); } + const vpmAliasPackageMatch = pathname.match(/^\/api\/vpm\/aliases\/([^/]+)\/([^/]+)\.zip$/); + if (vpmAliasPackageMatch && vpmRoutes) { + const catalogProductId = safeDecodeURIComponent(vpmAliasPackageMatch[1] ?? ''); + const version = safeDecodeURIComponent(vpmAliasPackageMatch[2] ?? ''); + if (catalogProductId === null || version === null) { + return badPathEncodingResponse(); + } + return vpmRoutes.serveAliasPackage(request, catalogProductId, version); + } const vpmIndexMatch = pathname.match(/^\/api\/vpm\/([^/]+)\/index\.json$/); if (vpmIndexMatch && vpmRoutes) { const token = safeDecodeURIComponent(vpmIndexMatch[1] ?? ''); diff --git a/apps/api/src/lib/env.test.ts b/apps/api/src/lib/env.test.ts index ebe2f8be2..8635d07ee 100644 --- a/apps/api/src/lib/env.test.ts +++ b/apps/api/src/lib/env.test.ts @@ -60,12 +60,16 @@ describe('loadEnv', () => { delete process.env.DELIVERY_HMAC_KEY; delete process.env.DELIVERY_BASE_URL; delete process.env.VPM_BASE_URL; + delete process.env.VPM_PUBLIC_INDEX_URL; + delete process.env.VPM_TOKEN_KEY; const env = loadEnv(); expect(env).toHaveProperty('DELIVERY_HMAC_KEY', undefined); expect(env).toHaveProperty('DELIVERY_BASE_URL', undefined); expect(env).toHaveProperty('VPM_BASE_URL', undefined); + expect(env).toHaveProperty('VPM_PUBLIC_INDEX_URL', undefined); + expect(env).toHaveProperty('VPM_TOKEN_KEY', undefined); }); it('includes Polar billing fields when present', () => { diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 90448dfed..398ba9215 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -41,6 +41,10 @@ export interface LocalEnv { DELIVERY_BASE_URL?: string; /** Optional public API origin used for buyer VPM index URLs. VPM routes return 503 when unavailable. */ VPM_BASE_URL?: string; + /** Public first-party VPM index that supplies the generic importer package. */ + VPM_PUBLIC_INDEX_URL?: string; + /** Purpose-separated HMAC key for stateless VPM repository tokens. */ + VPM_TOKEN_KEY?: string; VRCHAT_PENDING_STATE_SECRET?: string; VRCHAT_PROVIDER_SESSION_SECRET?: string; // Discord @@ -189,6 +193,8 @@ function loadFromEnv(): LocalEnv { DELIVERY_HMAC_KEY: process.env.DELIVERY_HMAC_KEY, DELIVERY_BASE_URL: process.env.DELIVERY_BASE_URL, VPM_BASE_URL: process.env.VPM_BASE_URL, + VPM_PUBLIC_INDEX_URL: process.env.VPM_PUBLIC_INDEX_URL, + VPM_TOKEN_KEY: process.env.VPM_TOKEN_KEY, VRCHAT_PENDING_STATE_SECRET: process.env.VRCHAT_PENDING_STATE_SECRET, VRCHAT_PROVIDER_SESSION_SECRET: process.env.VRCHAT_PROVIDER_SESSION_SECRET, DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, diff --git a/apps/api/src/routes/creatorUploads.test.ts b/apps/api/src/routes/creatorUploads.test.ts index b536ba5eb..0db2769eb 100644 --- a/apps/api/src/routes/creatorUploads.test.ts +++ b/apps/api/src/routes/creatorUploads.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { verifyUploadCapability } from '../../../../ops/storage-core/uploadSigning'; const convexQueryMock = mock(async (_reference?: unknown, _args?: unknown) => null as unknown); +const convexMutationMock = mock(async (_reference?: unknown, _args?: unknown) => null as unknown); const apiMock = { certificateBilling: { @@ -10,6 +11,7 @@ const apiMock = { packageRegistry: { getBuyerAccessContextByCatalogProductId: 'packageRegistry.getBuyerAccessContextByCatalogProductId', + claimPackageForCreatorUpload: 'packageRegistry.claimPackageForCreatorUpload', lookupRegistration: 'packageRegistry.lookupRegistration', }, } as const; @@ -26,6 +28,7 @@ mock.module('../lib/apiActor', () => ({ mock.module('../lib/convex', () => ({ getConvexClientFromUrl: () => ({ + mutation: convexMutationMock, query: convexQueryMock, }), })); @@ -76,6 +79,7 @@ describe('creator upload authorization', () => { }); beforeEach(() => { + convexMutationMock.mockReset(); convexQueryMock.mockReset(); }); @@ -107,6 +111,115 @@ describe('creator upload authorization', () => { }); }); + it('claims an unregistered package for the owned catalog product before first upload', async () => { + convexQueryMock.mockImplementation(async (reference: unknown) => { + if (reference === apiMock.packageRegistry.lookupRegistration) { + return null; + } + if (reference === apiMock.certificateBilling.getAccountOverview) { + return activeVpmBilling; + } + if (reference === apiMock.packageRegistry.getBuyerAccessContextByCatalogProductId) { + return { + catalogProductId: 'catalog-product-456', + creatorAuthUserId: 'creator-123', + }; + } + throw new Error(`Unexpected query ${String(reference)}`); + }); + convexMutationMock.mockResolvedValue({ + registered: true, + conflict: false, + archived: false, + }); + + const response = await createRoutes('creator-123').authorizeUpload( + authorizeRequest({ + packageId: 'com.yucp.first-upload', + version: '1.0.0', + catalogProductId: 'catalog-product-456', + }) + ); + + expect(response.status).toBe(200); + expect(convexMutationMock).toHaveBeenCalledWith( + apiMock.packageRegistry.claimPackageForCreatorUpload, + { + apiSecret: config.convexApiSecret, + actor: 'creator-actor-binding', + authUserId: 'creator-123', + catalogProductId: 'catalog-product-456', + packageId: 'com.yucp.first-upload', + } + ); + }); + + it('returns 409 when another creator wins the package namespace claim', async () => { + convexQueryMock.mockImplementation(async (reference: unknown) => { + if (reference === apiMock.packageRegistry.lookupRegistration) { + return null; + } + if (reference === apiMock.certificateBilling.getAccountOverview) { + return activeVpmBilling; + } + if (reference === apiMock.packageRegistry.getBuyerAccessContextByCatalogProductId) { + return { + catalogProductId: 'catalog-product-456', + creatorAuthUserId: 'creator-123', + }; + } + throw new Error(`Unexpected query ${String(reference)}`); + }); + convexMutationMock.mockResolvedValue({ + registered: false, + conflict: true, + archived: false, + }); + + const response = await createRoutes('creator-123').authorizeUpload( + authorizeRequest({ + packageId: 'com.yucp.contested', + version: '1.0.0', + catalogProductId: 'catalog-product-456', + }) + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: 'Package ID is already registered' }); + }); + + it('does not claim a package namespace when creator uploads are not configured', async () => { + convexQueryMock.mockImplementation(async (reference: unknown) => { + if (reference === apiMock.packageRegistry.lookupRegistration) { + return null; + } + if (reference === apiMock.certificateBilling.getAccountOverview) { + return activeVpmBilling; + } + if (reference === apiMock.packageRegistry.getBuyerAccessContextByCatalogProductId) { + return { + catalogProductId: 'catalog-product-456', + creatorAuthUserId: 'creator-123', + }; + } + throw new Error(`Unexpected query ${String(reference)}`); + }); + + const response = await createRoutes('creator-123', { + ingestTusUrl: undefined, + uploadHmacKey: undefined, + }).authorizeUpload( + authorizeRequest({ + packageId: 'com.yucp.unconfigured', + version: '1.0.0', + catalogProductId: 'catalog-product-456', + }) + ); + + expect(response.status).toBe(503); + expect(convexMutationMock).not.toHaveBeenCalled(); + }); + it('returns 503 for the package owner when creator uploads are not configured', async () => { convexQueryMock.mockImplementation(async (reference: unknown) => { if (reference === apiMock.packageRegistry.lookupRegistration) { diff --git a/apps/api/src/routes/creatorUploads.ts b/apps/api/src/routes/creatorUploads.ts index f0cf479ca..d26f2b6c0 100644 --- a/apps/api/src/routes/creatorUploads.ts +++ b/apps/api/src/routes/creatorUploads.ts @@ -1,4 +1,5 @@ import { api } from '../../../../convex/_generated/api'; +import type { Id } from '../../../../convex/_generated/dataModel'; import { BILLING_CAPABILITY_KEYS } from '../../../../convex/lib/billingCapabilities'; import { signUploadCapability, @@ -84,12 +85,12 @@ export function createCreatorUploadRoutes({ auth, config }: CreateCreatorUploadR packageId, })) as { status: 'active' | 'archived'; yucpUserId: string } | null; if ( - !registration || - registration.yucpUserId !== session.user.id || - registration.status !== 'active' + registration && + (registration.yucpUserId !== session.user.id || registration.status !== 'active') ) { return Response.json({ error: 'Active package ownership required' }, { status: 403 }); } + const billing = (await convex.query(api.certificateBilling.getAccountOverview, { apiSecret: config.convexApiSecret, authUserId: session.user.id, @@ -104,6 +105,11 @@ export function createCreatorUploadRoutes({ auth, config }: CreateCreatorUploadR if (!canUpload) { return Response.json({ error: 'VPM repository capability required' }, { status: 403 }); } + + if (!registration && !catalogProductId) { + return Response.json({ error: 'Catalog product ownership required' }, { status: 403 }); + } + if (catalogProductId) { const product = (await convex.query( api.packageRegistry.getBuyerAccessContextByCatalogProductId, @@ -113,10 +119,8 @@ export function createCreatorUploadRoutes({ auth, config }: CreateCreatorUploadR catalogProductId, } )) as { catalogProductId: string; creatorAuthUserId: string; packageId?: string } | null; - // packageId is only known once the catalog product has a READY version. Before that (e.g. the - // first upload) it is undefined and the product is not yet bound to any package, so only reject a - // CONCRETE mismatch — a product already bound to a different package. Creator ownership of the - // requested packageId is already proven by the registration check above. + // packageId is known after the catalog product has a READY version. + // Before that, only a concrete package mismatch is invalid. if ( !product || product.creatorAuthUserId !== session.user.id || @@ -133,6 +137,35 @@ export function createCreatorUploadRoutes({ auth, config }: CreateCreatorUploadR return Response.json({ error: 'Creator uploads are not configured' }, { status: 503 }); } + if (!registration) { + const claim = (await convex.mutation(api.packageRegistry.claimPackageForCreatorUpload, { + apiSecret: config.convexApiSecret, + actor, + authUserId: session.user.id, + catalogProductId: catalogProductId as Id<'product_catalog'>, + packageId, + })) as + | { registered: true; conflict: false; archived: false } + | { registered: false; conflict: true; archived: false } + | { registered: false; conflict: false; archived: true; reason: string } + | { + registered: false; + conflict: false; + archived: false; + catalogProductRejected: true; + }; + + if ('catalogProductRejected' in claim) { + return Response.json({ error: 'Catalog product ownership required' }, { status: 403 }); + } + if (claim.conflict) { + return Response.json({ error: 'Package ID is already registered' }, { status: 409 }); + } + if (claim.archived) { + return Response.json({ error: claim.reason }, { status: 403 }); + } + } + const versionId = crypto.randomUUID(); const capability = await signUploadCapability({ catalogProductId: catalogProductId ?? undefined, diff --git a/apps/api/src/routes/vpm.test.ts b/apps/api/src/routes/vpm.test.ts index f8c8c34ee..48252223a 100644 --- a/apps/api/src/routes/vpm.test.ts +++ b/apps/api/src/routes/vpm.test.ts @@ -1,10 +1,35 @@ import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; -import { verifyDeliveryUrl } from '../../../../ops/storage-core/deliverySigning'; +import { createHash } from 'node:crypto'; +import { unzipSync } from 'fflate'; import { signVpmRepoToken, verifyVpmRepoToken } from '../../../../ops/storage-core/vpmToken'; import { createTestLogger } from '../testSupport/loggerMock'; +import { buildYucpAliasVpmPackage, YUCP_ALIAS_BOOTSTRAP_VERSION } from './vpmAliasPackage'; const convexQueryMock = mock(async (_reference?: unknown, _args?: unknown) => null as unknown); const loggerErrorMock = mock(() => undefined); +const importerIndexFetchMock = mock(async () => + Response.json({ + packages: { + 'com.yucp.importer': { + versions: { + '0.1.14': { + name: 'com.yucp.importer', + displayName: 'YUCP Package Importer', + version: '0.1.14', + unity: '2022.3', + description: 'YUCP package importer', + author: { + name: 'YUCP Club', + url: 'https://vpm.yucp.club/', + }, + zipSHA256: 'a'.repeat(64), + url: 'https://packages.example.test/com.yucp.importer-0.1.14.zip', + }, + }, + }, + }, + }) +); const apiMock = { entitlements: { @@ -47,15 +72,15 @@ mock.module('../lib/logger', () => ({ const { createVpmRoutes } = await import('./vpm'); -const deliveryHmacKey = 'vpm-route-delivery-hmac-key-32-bytes'; +const vpmTokenKey = 'vpm-route-token-hmac-key-purpose-separated'; const config = { apiBaseUrl: 'https://api.test', frontendBaseUrl: 'https://app.test', convexApiSecret: 'test-convex-secret', convexUrl: 'https://convex.test', - deliveryBaseUrl: 'https://delivery.test/', - deliveryHmacKey, + publicVpmIndexUrl: 'https://vpm.yucp.club/index.json', vpmBaseUrl: 'https://vpm.test/', + vpmTokenKey, }; function createRoutes(userId: string | null, configOverrides: Partial = {}) { @@ -64,6 +89,7 @@ function createRoutes(userId: string | null, configOverrides: Partial (userId ? { user: { id: userId } } : null), } as never, config: { ...config, ...configOverrides }, + fetchImpl: importerIndexFetchMock as unknown as typeof fetch, }); } @@ -79,7 +105,7 @@ async function validBuyerToken(expiresAt = Date.now() + 30 * 24 * 60 * 60_000): await signVpmRepoToken({ authUserId: 'buyer-auth-user', expiresAt, - key: deliveryHmacKey, + key: vpmTokenKey, }) ).token; } @@ -92,6 +118,7 @@ describe('per-buyer VPM routes', () => { beforeEach(() => { convexQueryMock.mockReset(); loggerErrorMock.mockReset(); + importerIndexFetchMock.mockClear(); }); it('requires a Better Auth session to mint a repository token', async () => { @@ -103,23 +130,23 @@ describe('per-buyer VPM routes', () => { it('returns 503 from both routes when optional VPM delivery config is unavailable', async () => { const mintResponse = await createRoutes('buyer-auth-user', { - deliveryBaseUrl: undefined, - deliveryHmacKey: undefined, + publicVpmIndexUrl: undefined, vpmBaseUrl: undefined, + vpmTokenKey: undefined, }).mintRepoToken(mintRequest()); expect(mintResponse.status).toBe(503); const token = await validBuyerToken(); const indexResponse = await createRoutes(null, { - deliveryBaseUrl: undefined, + publicVpmIndexUrl: undefined, }).serveIndex(new Request(`https://api.test/api/vpm/${token}/index.json`), token); expect(indexResponse.status).toBe(503); expect(convexQueryMock).not.toHaveBeenCalled(); }); - it('requires HTTPS for remote delivery URLs while allowing loopback HTTP', async () => { + it('requires HTTPS for the public importer index while allowing loopback HTTP', async () => { const remoteHttpResponse = await createRoutes('buyer-auth-user', { - deliveryBaseUrl: 'http://delivery.test/', + publicVpmIndexUrl: 'http://packages.test/index.json', }).mintRepoToken(mintRequest()); expect(remoteHttpResponse.status).toBe(503); await expect(remoteHttpResponse.json()).resolves.toEqual({ @@ -127,12 +154,12 @@ describe('per-buyer VPM routes', () => { }); const httpsResponse = await createRoutes('buyer-auth-user', { - deliveryBaseUrl: 'https://delivery.test/', + publicVpmIndexUrl: 'https://packages.test/index.json', }).mintRepoToken(mintRequest()); expect(httpsResponse.status).toBe(200); const loopbackResponse = await createRoutes('buyer-auth-user', { - deliveryBaseUrl: 'http://localhost:8787/', + publicVpmIndexUrl: 'http://localhost:8787/index.json', }).mintRepoToken(mintRequest()); expect(loopbackResponse.status).toBe(200); }); @@ -174,7 +201,7 @@ describe('per-buyer VPM routes', () => { expect(body.addRepoUrl).toBe(`vcc://vpm/addRepo?url=${encodeURIComponent(body.indexUrl)}`); expect(body.expiresAt).toBeGreaterThanOrEqual(beforeRequest + 30 * 24 * 60 * 60_000 - 1_000); expect(body.expiresAt).toBeLessThanOrEqual(afterRequest + 30 * 24 * 60 * 60_000); - await expect(verifyVpmRepoToken({ key: deliveryHmacKey, token: body.token })).resolves.toEqual({ + await expect(verifyVpmRepoToken({ key: vpmTokenKey, token: body.token })).resolves.toEqual({ authUserId: 'buyer-auth-user', expiresAt: body.expiresAt, }); @@ -183,7 +210,7 @@ describe('per-buyer VPM routes', () => { it('does not log raw token-signing error messages', async () => { const rawUpstreamMessage = 'VPM repository token HMAC key must be at least 32 UTF-8 bytes'; const response = await createRoutes('buyer-auth-user', { - deliveryHmacKey: 'short-key', + vpmTokenKey: 'short-key', }).mintRepoToken(mintRequest()); expect(response.status).toBe(500); @@ -212,7 +239,22 @@ describe('per-buyer VPM routes', () => { expect(convexQueryMock).not.toHaveBeenCalled(); }); - it('serves the VCC repository schema with 1-hour signed READY-version URLs', async () => { + it('serves public alias bytes without repository-token or importer-index configuration', async () => { + const routes = createRoutes(null, { + publicVpmIndexUrl: undefined, + vpmTokenKey: undefined, + }); + const response = await routes.serveAliasPackage( + new Request('https://vpm.test/api/vpm/aliases/catalog_public/1.0.0.zip'), + 'catalog_public', + YUCP_ALIAS_BOOTSTRAP_VERSION + ); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + }); + + it('serves public aliases and the importer without paid package URLs', async () => { convexQueryMock.mockImplementation(async (reference: unknown, args: unknown) => { if (reference === apiMock.entitlements.listByAuthUser) { expect(args).toEqual({ @@ -260,12 +302,11 @@ describe('per-buyer VPM routes', () => { throw new Error(`Unexpected query ${String(reference)}`); }); const token = await validBuyerToken(); - const beforeRequest = Date.now(); - const response = await createRoutes(null).serveIndex( + const routes = createRoutes(null); + const response = await routes.serveIndex( new Request(`https://vpm.test/api/vpm/${token}/index.json`), token ); - const afterRequest = Date.now(); const body = (await response.json()) as { author: string; id: string; @@ -282,37 +323,59 @@ describe('per-buyer VPM routes', () => { id: 'club.yucp.buyer', url: `https://vpm.test/api/vpm/${token}/index.json`, packages: { - 'com.creator.avatar-tools': { + 'com.yucp.importer': { versions: { - '1.2.3': { - name: 'com.creator.avatar-tools', - displayName: 'com.creator.avatar-tools', - version: '1.2.3', - author: { - name: 'YUCP', - email: 'contact@yucp.club', - }, + '0.1.14': { + name: 'com.yucp.importer', + url: 'https://packages.example.test/com.yucp.importer-0.1.14.zip', }, }, }, }, }); - expect(Object.keys(body.packages)).toEqual(['com.creator.avatar-tools']); - const manifest = body.packages['com.creator.avatar-tools']?.versions['1.2.3']; - const deliveryUrl = new URL(String(manifest?.url)); - expect(deliveryUrl.pathname).toBe('/d/version-ready-123'); - const exp = deliveryUrl.searchParams.get('exp') ?? ''; - const sig = deliveryUrl.searchParams.get('sig') ?? ''; - expect(Number(exp) * 1_000).toBeGreaterThanOrEqual(beforeRequest + 60 * 60_000 - 1_000); - expect(Number(exp) * 1_000).toBeLessThanOrEqual(afterRequest + 60 * 60_000); - await expect( - verifyDeliveryUrl({ - exp, - key: deliveryHmacKey, - sig, - versionId: 'version-ready-123', - }) - ).resolves.toBe(true); + + const readyAlias = buildYucpAliasVpmPackage({ + catalogProductId: 'catalog_ready', + vpmBaseUrl: 'https://vpm.test/', + }); + const opaqueAlias = buildYucpAliasVpmPackage({ + catalogProductId: 'catalog_non_vpm', + vpmBaseUrl: 'https://vpm.test/', + }); + expect(Object.keys(body.packages).sort()).toEqual( + ['com.yucp.importer', readyAlias.packageId, opaqueAlias.packageId].sort() + ); + expect(body.packages[readyAlias.packageId]?.versions[YUCP_ALIAS_BOOTSTRAP_VERSION]).toEqual( + readyAlias.manifest + ); + expect(body.packages[opaqueAlias.packageId]?.versions[YUCP_ALIAS_BOOTSTRAP_VERSION]).toEqual( + opaqueAlias.manifest + ); + const serializedIndex = JSON.stringify(body); + expect(serializedIndex).not.toContain('/d/version-ready-123'); + expect(serializedIndex).not.toContain('version-non-vpm-456'); + expect(serializedIndex).not.toContain('sig='); + + const artifactResponse = await routes.serveAliasPackage( + new Request(readyAlias.manifest.url), + 'catalog_ready', + YUCP_ALIAS_BOOTSTRAP_VERSION + ); + const artifactBytes = new Uint8Array(await artifactResponse.arrayBuffer()); + expect(artifactResponse.status).toBe(200); + expect(artifactResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable' + ); + expect(createHash('sha256').update(artifactBytes).digest('hex')).toBe( + readyAlias.manifest.zipSHA256 + ); + const artifactEntries = unzipSync(artifactBytes); + const artifactPackageJson = JSON.parse( + Buffer.from(artifactEntries['package.json'] ?? []).toString('utf8') + ) as Record; + expect(artifactPackageJson.name).toBe(readyAlias.packageId); + expect(JSON.stringify(artifactPackageJson)).not.toContain('version-ready-123'); + expect(importerIndexFetchMock).toHaveBeenCalledTimes(1); expect(convexQueryMock).toHaveBeenCalledTimes(4); }); @@ -334,6 +397,53 @@ describe('per-buyer VPM routes', () => { }); }); + it('returns 503 when the public repository lacks the required importer release', async () => { + convexQueryMock.mockImplementation(async (reference: unknown) => { + if (reference === apiMock.entitlements.listByAuthUser) { + return { + data: [{ id: 'ent_1', catalogProductId: 'catalog_ready' }], + hasMore: false, + nextCursor: null, + }; + } + if (reference === apiMock.packageVersions.resolveDownloadableVersion) { + return { + packageId: 'com.creator.avatar-tools', + version: '1.2.3', + versionId: 'version-ready-123', + }; + } + throw new Error(`Unexpected query ${String(reference)}`); + }); + importerIndexFetchMock.mockImplementationOnce(async () => + Response.json({ + packages: { + 'com.yucp.importer': { + versions: { + '0.1.13': { + name: 'com.yucp.importer', + displayName: 'YUCP Package Importer', + version: '0.1.13', + zipSHA256: 'b'.repeat(64), + url: 'https://packages.example.test/com.yucp.importer-0.1.13.zip', + }, + }, + }, + }, + }) + ); + const token = await validBuyerToken(); + const response = await createRoutes(null).serveIndex( + new Request(`https://vpm.test/api/vpm/${token}/index.json`), + token + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: 'The public YUCP importer is not available', + }); + }); + it('does not log raw Convex error messages', async () => { const rawUpstreamMessage = 'Convex upstream leaked details'; convexQueryMock.mockRejectedValue(new TypeError(rawUpstreamMessage)); diff --git a/apps/api/src/routes/vpm.ts b/apps/api/src/routes/vpm.ts index 47353584f..b91c6c024 100644 --- a/apps/api/src/routes/vpm.ts +++ b/apps/api/src/routes/vpm.ts @@ -1,19 +1,25 @@ import { api } from '../../../../convex/_generated/api'; import type { Id } from '../../../../convex/_generated/dataModel'; -import { signDeliveryUrl } from '../../../../ops/storage-core/deliverySigning'; import { signVpmRepoToken, verifyVpmRepoToken } from '../../../../ops/storage-core/vpmToken'; import type { Auth } from '../auth'; import { createApiServiceActorBinding } from '../lib/apiActor'; import { getConvexClientFromUrl } from '../lib/convex'; import { rejectCrossSiteRequest } from '../lib/csrf'; import { logger } from '../lib/logger'; +import { buildYucpAliasVpmPackage, YUCP_ALIAS_BOOTSTRAP_VERSION } from './vpmAliasPackage'; +import { fetchPublicImporterManifest, type VpmImporterManifest } from './vpmImporterPackage'; const VPM_REPO_TOKEN_TTL_MS = 30 * 24 * 60 * 60_000; -// VCC can fetch a listing before the buyer chooses Install. One hour keeps that handoff usable -// without turning the package URL into a long-lived delivery capability. -const VPM_DELIVERY_URL_TTL_MS = 60 * 60_000; +const IMPORTER_MANIFEST_CACHE_MS = 5 * 60_000; const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +class PublicImporterUnavailableError extends Error { + constructor(options?: ErrorOptions) { + super('The public YUCP importer is not available', options); + this.name = 'PublicImporterUnavailableError'; + } +} + function isHttpsOrLoopbackHttp(url: URL): boolean { return ( url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTNAMES.has(url.hostname)) @@ -25,14 +31,15 @@ export interface VpmRouteConfig { frontendBaseUrl: string; convexApiSecret: string; convexUrl: string; - deliveryBaseUrl?: string; - deliveryHmacKey?: string; + publicVpmIndexUrl?: string; vpmBaseUrl?: string; + vpmTokenKey?: string; } interface CreateVpmRoutesOptions { auth: Auth; config: VpmRouteConfig; + fetchImpl?: typeof fetch; } type ActiveEntitlement = { @@ -40,37 +47,15 @@ type ActiveEntitlement = { }; type DownloadableVersion = { - contentType?: string; packageId: string; - packageName?: string; version: string; versionId: string; }; -const VPM_CONTENT_TYPES = new Set(['application/zip', 'application/x-zip-compressed']); - -function isVpmCompatibleRelease( - release: DownloadableVersion | null -): release is DownloadableVersion { - const contentType = release?.contentType?.split(';', 1)[0]?.trim().toLowerCase(); - return Boolean(contentType && VPM_CONTENT_TYPES.has(contentType)); -} - -type VpmVersionManifest = { - author: { - email: string; - name: string; - }; - displayName: string; - name: string; - url: string; - version: string; -}; - type VpmRepositoryPackages = Record< string, { - versions: Record; + versions: Record>; } >; @@ -84,30 +69,56 @@ function jsonNoStore(body: unknown, init?: ResponseInit): Response { return Response.json(body, { ...init, headers }); } -function getConfiguredVpmDelivery(config: VpmRouteConfig): { - deliveryBaseUrl: string; - deliveryHmacKey: string; - vpmBaseUrl: string; -} | null { - const deliveryBaseUrl = config.deliveryBaseUrl?.trim(); - const deliveryHmacKey = config.deliveryHmacKey?.trim(); +function getConfiguredVpmBaseUrl(config: VpmRouteConfig): string | null { const vpmBaseUrl = config.vpmBaseUrl?.trim(); - if (!deliveryBaseUrl || !deliveryHmacKey || !vpmBaseUrl) { + if (!vpmBaseUrl) { return null; } try { - const deliveryUrl = new URL(deliveryBaseUrl); const vpmUrl = new URL(vpmBaseUrl); - if (!isHttpsOrLoopbackHttp(deliveryUrl) || !isHttpsOrLoopbackHttp(vpmUrl)) { + if ( + !isHttpsOrLoopbackHttp(vpmUrl) || + vpmUrl.username || + vpmUrl.password || + vpmUrl.search || + vpmUrl.hash + ) { + return null; + } + } catch { + return null; + } + return vpmBaseUrl.replace(/\/+$/, ''); +} + +function getConfiguredVpmRepository(config: VpmRouteConfig): { + publicVpmIndexUrl: string; + vpmBaseUrl: string; + vpmTokenKey: string; +} | null { + const publicVpmIndexUrl = config.publicVpmIndexUrl?.trim(); + const vpmBaseUrl = getConfiguredVpmBaseUrl(config); + const vpmTokenKey = config.vpmTokenKey?.trim(); + if (!publicVpmIndexUrl || !vpmBaseUrl || !vpmTokenKey) { + return null; + } + try { + const publicIndexUrl = new URL(publicVpmIndexUrl); + if ( + !isHttpsOrLoopbackHttp(publicIndexUrl) || + publicIndexUrl.username || + publicIndexUrl.password || + publicIndexUrl.hash + ) { return null; } } catch { return null; } return { - deliveryBaseUrl: deliveryBaseUrl.replace(/\/+$/, ''), - deliveryHmacKey, - vpmBaseUrl: vpmBaseUrl.replace(/\/+$/, ''), + publicVpmIndexUrl, + vpmBaseUrl, + vpmTokenKey, }; } @@ -123,7 +134,34 @@ function buildAddRepoUrl(indexUrl: string): string { return addRepoUrl.toString(); } -export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { +export function createVpmRoutes({ auth, config, fetchImpl = fetch }: CreateVpmRoutesOptions) { + let importerManifestCache: + | { + expiresAt: number; + manifest: VpmImporterManifest; + } + | undefined; + + async function getImporterManifest(publicVpmIndexUrl: string): Promise { + if (importerManifestCache && importerManifestCache.expiresAt > Date.now()) { + return importerManifestCache.manifest; + } + let manifest: VpmImporterManifest; + try { + manifest = await fetchPublicImporterManifest({ + fetchImpl, + publicVpmIndexUrl, + }); + } catch (error) { + throw new PublicImporterUnavailableError({ cause: error }); + } + importerManifestCache = { + expiresAt: Date.now() + IMPORTER_MANIFEST_CACHE_MS, + manifest, + }; + return manifest; + } + async function mintRepoToken(request: Request): Promise { if (request.method !== 'POST') { return Response.json({ error: 'Method not allowed' }, { status: 405 }); @@ -136,8 +174,8 @@ export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { if (!session) { return Response.json({ error: 'Authentication required' }, { status: 401 }); } - const vpmDelivery = getConfiguredVpmDelivery(config); - if (!vpmDelivery) { + const vpmRepository = getConfiguredVpmRepository(config); + if (!vpmRepository) { return Response.json({ error: 'VPM delivery is not configured' }, { status: 503 }); } @@ -145,9 +183,9 @@ export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { const signed = await signVpmRepoToken({ authUserId: session.user.id, expiresAt: Date.now() + VPM_REPO_TOKEN_TTL_MS, - key: vpmDelivery.deliveryHmacKey, + key: vpmRepository.vpmTokenKey, }); - const indexUrl = buildIndexUrl(vpmDelivery.vpmBaseUrl, signed.token); + const indexUrl = buildIndexUrl(vpmRepository.vpmBaseUrl, signed.token); return jsonNoStore({ token: signed.token, expiresAt: signed.expiresAt, @@ -166,12 +204,12 @@ export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { if (request.method !== 'GET') { return Response.json({ error: 'Method not allowed' }, { status: 405 }); } - const vpmDelivery = getConfiguredVpmDelivery(config); - if (!vpmDelivery) { + const vpmRepository = getConfiguredVpmRepository(config); + if (!vpmRepository) { return Response.json({ error: 'VPM delivery is not configured' }, { status: 503 }); } const verified = await verifyVpmRepoToken({ - key: vpmDelivery.deliveryHmacKey, + key: vpmRepository.vpmTokenKey, token, }); if (!verified) { @@ -215,49 +253,43 @@ export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { ), ]; const downloadableVersions = await Promise.all( - catalogProductIds.map( - async (catalogProductId) => - (await convex.query(api.packageVersions.resolveDownloadableVersion, { - apiSecret: config.convexApiSecret, - actor, - catalogProductId: catalogProductId as Id<'product_catalog'>, - })) as DownloadableVersion | null - ) + catalogProductIds.map(async (catalogProductId) => ({ + catalogProductId, + release: (await convex.query(api.packageVersions.resolveDownloadableVersion, { + apiSecret: config.convexApiSecret, + actor, + catalogProductId: catalogProductId as Id<'product_catalog'>, + })) as DownloadableVersion | null, + })) + ); + const aliases = downloadableVersions.flatMap(({ catalogProductId, release }) => + release + ? [ + buildYucpAliasVpmPackage({ + catalogProductId, + vpmBaseUrl: vpmRepository.vpmBaseUrl, + }), + ] + : [] ); - const vpmReleases = downloadableVersions.filter(isVpmCompatibleRelease); - const packages = vpmReleases.reduce((repository, release) => { - const packageEntry = repository[release.packageId] ?? { versions: {} }; - repository[release.packageId] = packageEntry; + const packages = aliases.reduce((repository, alias) => { + repository[alias.packageId] = { + versions: { + [YUCP_ALIAS_BOOTSTRAP_VERSION]: alias.manifest, + }, + }; return repository; }, {}); + if (aliases.length > 0) { + const importerManifest = await getImporterManifest(vpmRepository.publicVpmIndexUrl); + packages[importerManifest.name] = { + versions: { + [importerManifest.version]: importerManifest, + }, + }; + } - await Promise.all( - vpmReleases.map(async (release) => { - const signature = await signDeliveryUrl({ - versionId: release.versionId, - key: vpmDelivery.deliveryHmacKey, - expiresAt: Date.now() + VPM_DELIVERY_URL_TTL_MS, - }); - const packageEntry = packages[release.packageId]; - if (!packageEntry) { - return; - } - packageEntry.versions[release.version] = { - name: release.packageId, - displayName: release.packageName?.trim() || release.packageId, - version: release.version, - author: { - name: 'YUCP', - email: 'contact@yucp.club', - }, - url: `${vpmDelivery.deliveryBaseUrl}/d/${encodeURIComponent( - release.versionId - )}?exp=${signature.exp}&sig=${signature.sig}`, - }; - }) - ); - - const indexUrl = buildIndexUrl(vpmDelivery.vpmBaseUrl, token); + const indexUrl = buildIndexUrl(vpmRepository.vpmBaseUrl, token); return jsonNoStore({ name: 'YUCP Buyer Packages', author: 'YUCP', @@ -266,6 +298,15 @@ export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { packages, }); } catch (error) { + if (error instanceof PublicImporterUnavailableError) { + logger.warn('Public YUCP importer is unavailable for the buyer VPM index', { + errorName: error.name, + }); + return Response.json( + { error: 'The public YUCP importer is not available' }, + { status: 503 } + ); + } logger.error('Failed to build buyer VPM repository index', { errorName: error instanceof Error ? error.name : 'UnknownError', }); @@ -273,5 +314,43 @@ export function createVpmRoutes({ auth, config }: CreateVpmRoutesOptions) { } } - return { mintRepoToken, serveIndex }; + async function serveAliasPackage( + request: Request, + catalogProductId: string, + version: string + ): Promise { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }); + } + const vpmBaseUrl = getConfiguredVpmBaseUrl(config); + if (!vpmBaseUrl) { + return Response.json({ error: 'VPM delivery is not configured' }, { status: 503 }); + } + if (version !== YUCP_ALIAS_BOOTSTRAP_VERSION) { + return Response.json({ error: 'VPM alias package not found' }, { status: 404 }); + } + + try { + const built = buildYucpAliasVpmPackage({ + catalogProductId, + vpmBaseUrl, + }); + const headers = new Headers({ + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Disposition': `attachment; filename="${built.packageId}-${version}.zip"`, + 'Content-Length': String(built.bytes.byteLength), + 'Content-Type': 'application/zip', + ETag: `"${built.zipSha256}"`, + }); + const responseBody = request.method === 'HEAD' ? null : Uint8Array.from(built.bytes).buffer; + return new Response(responseBody, { + status: 200, + headers, + }); + } catch { + return Response.json({ error: 'VPM alias package not found' }, { status: 404 }); + } + } + + return { mintRepoToken, serveAliasPackage, serveIndex }; } diff --git a/apps/api/src/routes/vpmAliasPackage.test.ts b/apps/api/src/routes/vpmAliasPackage.test.ts new file mode 100644 index 000000000..c02fec130 --- /dev/null +++ b/apps/api/src/routes/vpmAliasPackage.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { unzipSync } from 'fflate'; +import { buildYucpAliasVpmPackage, YUCP_ALIAS_BOOTSTRAP_VERSION } from './vpmAliasPackage'; + +const catalogProductId = 'catalog_product_public_alias_123'; + +describe('YUCP public VPM alias package', () => { + it('builds one deterministic package.json-only archive', () => { + const first = buildYucpAliasVpmPackage({ + catalogProductId, + vpmBaseUrl: 'https://vpm.example.test/', + }); + const second = buildYucpAliasVpmPackage({ + catalogProductId, + vpmBaseUrl: 'https://vpm.example.test/', + }); + + expect(first.bytes).toEqual(second.bytes); + expect(first.zipSha256).toBe(createHash('sha256').update(first.bytes).digest('hex')); + + const entries = unzipSync(first.bytes); + expect(Object.keys(entries)).toEqual(['package.json']); + }); + + it('contains only public bootstrap metadata and the importer dependency', () => { + const built = buildYucpAliasVpmPackage({ + catalogProductId, + vpmBaseUrl: 'https://vpm.example.test/', + }); + const entries = unzipSync(built.bytes); + const packageJson = JSON.parse( + Buffer.from(entries['package.json'] ?? []).toString('utf8') + ) as Record; + + expect(built.manifest).toMatchObject({ + name: built.packageId, + version: YUCP_ALIAS_BOOTSTRAP_VERSION, + url: `https://vpm.example.test/api/vpm/aliases/${encodeURIComponent( + catalogProductId + )}/${YUCP_ALIAS_BOOTSTRAP_VERSION}.zip`, + zipSHA256: built.zipSha256, + vpmDependencies: { + 'com.yucp.importer': '>=0.1.14', + }, + yucp: { + kind: 'alias-v1', + aliasId: catalogProductId, + catalogProductIds: [catalogProductId], + channel: 'stable', + installStrategy: 'server-authorized', + importerPackage: 'com.yucp.importer', + minImporterVersion: '0.1.14', + }, + }); + expect(packageJson).toEqual({ + name: built.packageId, + displayName: built.manifest.displayName, + version: YUCP_ALIAS_BOOTSTRAP_VERSION, + unity: '2022.3', + description: + 'Public YUCP bootstrap. Sign in through the importer to resolve licensed product content.', + author: { + name: 'YUCP Club', + email: 'contact@yucp.club', + url: 'https://yucp.club/', + }, + vpmDependencies: { + 'com.yucp.importer': '>=0.1.14', + }, + yucp: built.manifest.yucp, + }); + + const serialized = JSON.stringify(packageJson); + expect(serialized).not.toContain('versionId'); + expect(serialized).not.toContain('delivery'); + expect(serialized).not.toContain('download'); + expect(serialized).not.toContain('token'); + expect(serialized).not.toContain('sig'); + }); + + it('rejects unsafe origins and unbounded catalog product identifiers', () => { + expect(() => + buildYucpAliasVpmPackage({ + catalogProductId, + vpmBaseUrl: 'http://vpm.example.test/', + }) + ).toThrow('VPM base URL'); + expect(() => + buildYucpAliasVpmPackage({ + catalogProductId: 'x'.repeat(513), + vpmBaseUrl: 'https://vpm.example.test/', + }) + ).toThrow('catalog product ID'); + }); +}); diff --git a/apps/api/src/routes/vpmAliasPackage.ts b/apps/api/src/routes/vpmAliasPackage.ts new file mode 100644 index 000000000..64918af19 --- /dev/null +++ b/apps/api/src/routes/vpmAliasPackage.ts @@ -0,0 +1,124 @@ +import { createHash } from 'node:crypto'; +import { applyYucpAliasPackageManifestDefaults, mergeYucpAliasPackageMetadata } from '@yucp/shared'; +import { strToU8, zipSync } from 'fflate'; + +export const YUCP_ALIAS_BOOTSTRAP_VERSION = '1.0.0'; + +const MAX_CATALOG_PRODUCT_ID_LENGTH = 512; +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const ZIP_TIMESTAMP = new Date('1980-01-01T00:00:00.000Z'); + +export type YucpAliasVpmManifest = { + author: { + email: string; + name: string; + url: string; + }; + description: string; + displayName: string; + name: string; + unity: string; + url: string; + version: string; + vpmDependencies: Record; + yucp: Record; + zipSHA256: string; +}; + +export type BuiltYucpAliasVpmPackage = { + bytes: Uint8Array; + manifest: YucpAliasVpmManifest; + packageId: string; + zipSha256: string; +}; + +function normalizeVpmBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('VPM base URL must be an absolute HTTPS or loopback HTTP URL'); + } + if ( + url.protocol !== 'https:' && + !(url.protocol === 'http:' && LOOPBACK_HOSTNAMES.has(url.hostname)) + ) { + throw new Error('VPM base URL must be an absolute HTTPS or loopback HTTP URL'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('VPM base URL must not contain credentials, a query, or a fragment'); + } + return url.toString().replace(/\/+$/, ''); +} + +function normalizeCatalogProductId(value: string): string { + const normalized = value.trim(); + const hasControlCharacter = Array.from(normalized).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); + if (!normalized || normalized.length > MAX_CATALOG_PRODUCT_ID_LENGTH || hasControlCharacter) { + throw new Error('YUCP catalog product ID must contain 1 through 512 safe characters'); + } + return normalized; +} + +export function buildYucpAliasVpmPackageId(catalogProductId: string): string { + const normalizedCatalogProductId = normalizeCatalogProductId(catalogProductId); + const identity = createHash('sha256').update(normalizedCatalogProductId, 'utf8').digest('hex'); + return `com.yucp.alias.${identity.slice(0, 32)}`; +} + +export function buildYucpAliasVpmPackage(input: { + catalogProductId: string; + vpmBaseUrl: string; +}): BuiltYucpAliasVpmPackage { + const catalogProductId = normalizeCatalogProductId(input.catalogProductId); + const vpmBaseUrl = normalizeVpmBaseUrl(input.vpmBaseUrl); + const packageId = buildYucpAliasVpmPackageId(catalogProductId); + const displayName = `YUCP Product Bootstrap ${packageId.slice(-8).toUpperCase()}`; + const packageJson = applyYucpAliasPackageManifestDefaults( + mergeYucpAliasPackageMetadata({ + metadata: { + name: packageId, + displayName, + version: YUCP_ALIAS_BOOTSTRAP_VERSION, + unity: '2022.3', + description: + 'Public YUCP bootstrap. Sign in through the importer to resolve licensed product content.', + author: { + name: 'YUCP Club', + email: 'contact@yucp.club', + url: 'https://yucp.club/', + }, + }, + aliasId: catalogProductId, + catalogProductIds: [catalogProductId], + channel: 'stable', + }) + ) as Omit; + const bytes = zipSync( + { + 'package.json': [ + strToU8(`${JSON.stringify(packageJson, null, 2)}\n`), + { level: 9, mtime: ZIP_TIMESTAMP }, + ], + }, + { level: 9 } + ); + const zipSha256 = createHash('sha256').update(bytes).digest('hex'); + const artifactUrl = `${vpmBaseUrl}/api/vpm/aliases/${encodeURIComponent( + catalogProductId + )}/${YUCP_ALIAS_BOOTSTRAP_VERSION}.zip`; + + return { + bytes, + packageId, + zipSha256, + manifest: { + ...packageJson, + url: artifactUrl, + zipSHA256: zipSha256, + }, + }; +} diff --git a/apps/api/src/routes/vpmImporterPackage.ts b/apps/api/src/routes/vpmImporterPackage.ts new file mode 100644 index 000000000..4f091ad25 --- /dev/null +++ b/apps/api/src/routes/vpmImporterPackage.ts @@ -0,0 +1,139 @@ +import { YUCP_ALIAS_PACKAGE_DEFAULT_IMPORTER_MIN_VERSION } from '@yucp/shared'; + +const IMPORTER_PACKAGE_ID = 'com.yucp.importer'; +const MAX_PUBLIC_INDEX_BYTES = 2 * 1024 * 1024; +const PUBLIC_INDEX_TIMEOUT_MS = 10_000; +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +export type VpmImporterManifest = Record & { + displayName: string; + name: typeof IMPORTER_PACKAGE_ID; + url: string; + version: string; + zipSHA256: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseStableVersion(value: string): [number, number, number] | null { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value); + if (!match) { + return null; + } + const parts = match.slice(1).map((part) => Number.parseInt(part, 10)); + if (parts.some((part) => !Number.isSafeInteger(part))) { + return null; + } + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +function compareVersions(left: [number, number, number], right: [number, number, number]): number { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +function requirePublicPackageUrl(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('The public importer manifest is missing its package URL'); + } + const url = new URL(value); + if ( + url.protocol !== 'https:' && + !(url.protocol === 'http:' && LOOPBACK_HOSTNAMES.has(url.hostname)) + ) { + throw new Error('The public importer package URL must use HTTPS or loopback HTTP'); + } + if (url.username || url.password || url.hash) { + throw new Error('The public importer package URL must not contain credentials or a fragment'); + } + return url.toString(); +} + +export function selectPublicImporterManifest(value: unknown): VpmImporterManifest { + if (!isRecord(value) || !isRecord(value.packages)) { + throw new Error('The public VPM index does not contain a packages object'); + } + const packageEntry = value.packages[IMPORTER_PACKAGE_ID]; + if (!isRecord(packageEntry) || !isRecord(packageEntry.versions)) { + throw new Error('The public VPM index does not contain the importer package'); + } + + const minimum = parseStableVersion(YUCP_ALIAS_PACKAGE_DEFAULT_IMPORTER_MIN_VERSION); + if (!minimum) { + throw new Error('The configured importer minimum version is invalid'); + } + + const candidates = Object.entries(packageEntry.versions) + .flatMap(([version, manifest]) => { + const parsed = parseStableVersion(version); + return parsed && compareVersions(parsed, minimum) >= 0 && isRecord(manifest) + ? [{ manifest, parsed, version }] + : []; + }) + .sort((left, right) => compareVersions(right.parsed, left.parsed)); + const selected = candidates[0]; + if (!selected) { + throw new Error( + `The public VPM index requires ${IMPORTER_PACKAGE_ID} ${YUCP_ALIAS_PACKAGE_DEFAULT_IMPORTER_MIN_VERSION} or newer` + ); + } + + const displayName = selected.manifest.displayName; + const zipSHA256 = selected.manifest.zipSHA256; + if ( + selected.manifest.name !== IMPORTER_PACKAGE_ID || + selected.manifest.version !== selected.version || + typeof displayName !== 'string' || + !displayName.trim() || + typeof zipSHA256 !== 'string' || + !/^[0-9a-f]{64}$/i.test(zipSHA256) + ) { + throw new Error('The public importer manifest is invalid'); + } + + return { + ...selected.manifest, + name: IMPORTER_PACKAGE_ID, + displayName: displayName.trim(), + version: selected.version, + url: requirePublicPackageUrl(selected.manifest.url), + zipSHA256: zipSHA256.toLowerCase(), + }; +} + +export async function fetchPublicImporterManifest(input: { + fetchImpl: typeof fetch; + publicVpmIndexUrl: string; +}): Promise { + // VPM repository format: https://vcc.docs.vrchat.com/vpm/repos/ + const response = await input.fetchImpl(input.publicVpmIndexUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + redirect: 'error', + signal: AbortSignal.timeout(PUBLIC_INDEX_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`The public VPM index returned HTTP ${response.status}`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_PUBLIC_INDEX_BYTES) { + throw new Error('The public VPM index exceeded its response limit'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new Error('The public VPM index returned invalid JSON'); + } + return selectPublicImporterManifest(parsed); +} diff --git a/apps/api/test/e2e/buyer-delivery-flow.e2e.test.ts b/apps/api/test/e2e/buyer-delivery-flow.e2e.test.ts index 4be626ef8..f5bf2fab3 100644 --- a/apps/api/test/e2e/buyer-delivery-flow.e2e.test.ts +++ b/apps/api/test/e2e/buyer-delivery-flow.e2e.test.ts @@ -42,6 +42,10 @@ import { waitForPostgres } from '../../../../ops/testing/postgresReadiness'; import { createAuth } from '../../src/auth'; import { createConnectUserProductAccessRoutes } from '../../src/routes/connectUserProductAccess'; import { createVpmRoutes } from '../../src/routes/vpm'; +import { + buildYucpAliasVpmPackage, + YUCP_ALIAS_BOOTSTRAP_VERSION, +} from '../../src/routes/vpmAliasPackage'; import { createBetterAuthSession, createBetterAuthUser, @@ -385,6 +389,7 @@ test('delivers the entitled buyer full byte-exact multi-chunk package through th const readonlyAccessKey = `readonly-${randomBytes(12).toString('hex')}`; const readonlySecretKey = randomBytes(32).toString('hex'); const deliveryHmacKey = randomBytes(32).toString('hex'); + const vpmTokenKey = randomBytes(32).toString('hex'); const bucket = `buyer-delivery-${randomBytes(8).toString('hex')}`; const postgresName = `yucp-buyer-delivery-postgres-${randomUUID()}`; const minioName = `yucp-buyer-delivery-minio-${randomUUID()}`; @@ -665,10 +670,28 @@ test('delivers the entitled buyer full byte-exact multi-chunk package through th frontendBaseUrl: 'http://127.0.0.1:3000', convexApiSecret: API_SECRET, convexUrl: BACKEND_URL, - deliveryBaseUrl, - deliveryHmacKey, + publicVpmIndexUrl: `${API_BASE_URL}/test-public-vpm/index.json`, vpmBaseUrl: API_BASE_URL, + vpmTokenKey, }, + fetchImpl: (async () => + Response.json({ + packages: { + 'com.yucp.importer': { + versions: { + '0.1.14': { + name: 'com.yucp.importer', + displayName: 'YUCP Package Importer', + version: '0.1.14', + unity: '2022.3', + author: { name: 'YUCP Club' }, + zipSHA256: 'a'.repeat(64), + url: `${API_BASE_URL}/test-public-vpm/com.yucp.importer-0.1.14.zip`, + }, + }, + }, + }, + })) as unknown as typeof fetch, }); const buyerSession = await createBetterAuthSession(buyer.authUserId); @@ -694,7 +717,12 @@ test('delivers the entitled buyer full byte-exact multi-chunk package through th String(catalogProductId) ); expect(licenseOkResponse.status).toBe(302); - expect(licenseOkResponse.headers.get('location')).toContain(`/d/${ready.id}?`); + const authorizedDownloadLocation = licenseOkResponse.headers.get('location'); + expect(authorizedDownloadLocation).toContain(`/d/${ready.id}?`); + if (!authorizedDownloadLocation) { + throw new Error('Buyer download route did not return a delivery location'); + } + const deliveryUrl = new URL(authorizedDownloadLocation); const repoTokenResponse = await vpmRoutes.mintRepoToken( new Request(`${API_BASE_URL}/api/vpm/repo-token`, { @@ -718,12 +746,36 @@ test('delivers the entitled buyer full byte-exact multi-chunk package through th expect(indexResponse.status).toBe(200); const indexText = await indexResponse.text(); const index = JSON.parse(indexText) as VpmIndex; - const packageVersion = index.packages[PACKAGE_ID]?.versions[PACKAGE_VERSION]; - expect(packageVersion).toBeDefined(); - if (!packageVersion) { - throw new Error('VPM index did not list the READY package version'); + const alias = buildYucpAliasVpmPackage({ + catalogProductId: String(catalogProductId), + vpmBaseUrl: API_BASE_URL, + }); + const aliasVersion = index.packages[alias.packageId]?.versions[YUCP_ALIAS_BOOTSTRAP_VERSION]; + expect(aliasVersion).toEqual(alias.manifest); + expect(index.packages['com.yucp.importer']?.versions['0.1.14']).toBeDefined(); + expect(indexText).not.toContain(`/d/${ready.id}`); + expect(indexText).not.toContain(deliveryHmacKey); + + const aliasResponse = await vpmRoutes.serveAliasPackage( + new Request(alias.manifest.url), + String(catalogProductId), + YUCP_ALIAS_BOOTSTRAP_VERSION + ); + expect(aliasResponse.status).toBe(200); + const aliasBytes = new Uint8Array(await aliasResponse.arrayBuffer()); + expect(sha256Bytes(aliasBytes)).toBe(alias.zipSha256); + const aliasZip = unzipSync(aliasBytes); + expect(JSON.parse(Buffer.from(aliasZip['package.json'] ?? []).toString('utf8'))).toMatchObject({ + name: alias.packageId, + yucp: { + kind: 'alias-v1', + catalogProductIds: [String(catalogProductId)], + }, + }); + if (!aliasVersion) { + throw new Error('VPM index did not list the public product alias'); } - const deliveryUrl = new URL(packageVersion.url); + expect(deliveryUrl.origin).toBe(deliveryBaseUrl); expect(deliveryUrl.pathname).toBe(`/d/${ready.id}`); expect(deliveryUrl.searchParams.get('exp')).toBeTruthy(); diff --git a/apps/api/test/index.test.ts b/apps/api/test/index.test.ts index 602edd0ca..e6b28c72f 100644 --- a/apps/api/test/index.test.ts +++ b/apps/api/test/index.test.ts @@ -80,6 +80,8 @@ describe('API server, production app harness', () => { expect(process.env.DELIVERY_HMAC_KEY).toBeUndefined(); expect(process.env.DELIVERY_BASE_URL).toBeUndefined(); expect(process.env.VPM_BASE_URL).toBeUndefined(); + expect(process.env.VPM_PUBLIC_INDEX_URL).toBeUndefined(); + expect(process.env.VPM_TOKEN_KEY).toBeUndefined(); const res = await app.fetch('/health'); expect(res.status).toBe(200); diff --git a/apps/api/test/support/buildApp.ts b/apps/api/test/support/buildApp.ts index d6f6b208f..c26cdb6a4 100644 --- a/apps/api/test/support/buildApp.ts +++ b/apps/api/test/support/buildApp.ts @@ -19,6 +19,8 @@ const MANAGED_ENV_KEYS = [ 'DELIVERY_HMAC_KEY', 'DELIVERY_BASE_URL', 'VPM_BASE_URL', + 'VPM_PUBLIC_INDEX_URL', + 'VPM_TOKEN_KEY', 'VRCHAT_PENDING_STATE_SECRET', 'VRCHAT_PROVIDER_SESSION_SECRET', 'DISCORD_CLIENT_ID', @@ -101,6 +103,8 @@ function buildEnv(config: BuildAppConfig): ManagedEnv { DELIVERY_HMAC_KEY: undefined, DELIVERY_BASE_URL: undefined, VPM_BASE_URL: undefined, + VPM_PUBLIC_INDEX_URL: undefined, + VPM_TOKEN_KEY: undefined, VRCHAT_PENDING_STATE_SECRET: 'test-vrchat-pending-state-secret', VRCHAT_PROVIDER_SESSION_SECRET: 'test-vrchat-provider-session-secret', DISCORD_CLIENT_ID: config.discordClientId ?? 'test-discord-client-id', diff --git a/apps/web/src/components/dashboard/AuthRequiredState.tsx b/apps/web/src/components/dashboard/AuthRequiredState.tsx index 1917c6121..e45446a09 100644 --- a/apps/web/src/components/dashboard/AuthRequiredState.tsx +++ b/apps/web/src/components/dashboard/AuthRequiredState.tsx @@ -1,3 +1,5 @@ +import { Icon } from '@/components/ui/Icon'; + export function DashboardAuthRequiredState({ title, description, @@ -11,20 +13,7 @@ export function DashboardAuthRequiredState({
- +

{title} diff --git a/apps/web/src/components/dashboard/CertificateWorkspacePanels.tsx b/apps/web/src/components/dashboard/CertificateWorkspacePanels.tsx index 92e0c8f01..9fa9c7ddc 100644 --- a/apps/web/src/components/dashboard/CertificateWorkspacePanels.tsx +++ b/apps/web/src/components/dashboard/CertificateWorkspacePanels.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { AccountModal } from '@/components/account/AccountPage'; +import { Icon } from '@/components/ui/Icon'; import { type BadgeStatus, StatusChip } from '@/components/ui/StatusChip'; import { YucpButton } from '@/components/ui/YucpButton'; import { @@ -78,12 +79,7 @@ export function CertificateDeviceRow({ className="account-list-row-icon" style={{ background: isActive ? 'rgba(34,197,94,0.1)' : 'rgba(148,163,184,0.1)' }} > - +

diff --git a/apps/web/src/components/dashboard/CouplingForensicsPanel.tsx b/apps/web/src/components/dashboard/CouplingForensicsPanel.tsx index 55911e8ef..84be63146 100644 --- a/apps/web/src/components/dashboard/CouplingForensicsPanel.tsx +++ b/apps/web/src/components/dashboard/CouplingForensicsPanel.tsx @@ -108,38 +108,12 @@ const VERDICT_CONFIG = { function FxNoteIcon({ tone }: { tone: FxTone }) { if (tone === 'success') { - return ( - - ); + return ; } if (tone === 'warning' || tone === 'danger') { - return ( - - ); + return ; } - return ( - - ); + return ; } function FxNote({ @@ -387,12 +361,7 @@ export function CouplingForensicsPanel({ initialPackageId }: { initialPackageId?
- +

Creator scope required

@@ -578,20 +547,7 @@ export function CouplingForensicsPanel({ initialPackageId }: { initialPackageId? {selectedFile ? (
- +

@@ -613,19 +569,7 @@ export function CouplingForensicsPanel({ initialPackageId }: { initialPackageId? if (fileInputRef.current) fileInputRef.current.value = ''; }} > - + - +

{isDragOver ? 'Drop to upload' : 'Click to upload or drag & drop'} diff --git a/apps/web/src/components/dashboard/DashboardHeader.tsx b/apps/web/src/components/dashboard/DashboardHeader.tsx index 11150cafd..331966fa3 100644 --- a/apps/web/src/components/dashboard/DashboardHeader.tsx +++ b/apps/web/src/components/dashboard/DashboardHeader.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router'; +import { Icon } from '@/components/ui/Icon'; import { useTheme } from '@/hooks/useTheme'; import { getServerIconUrl } from '@/lib/utils'; @@ -36,20 +37,7 @@ export function DashboardHeader({ const contextIcon = selectedGuild?.icon ? ( ) : ( - + ); const homeIconLink = @@ -97,7 +85,7 @@ export function DashboardHeader({ aria-label="Documentation" title="Creator docs" > - +

diff --git a/apps/web/src/components/dashboard/PackageRegistryAccessGate.tsx b/apps/web/src/components/dashboard/PackageRegistryAccessGate.tsx index a14c414ed..9901d1c9b 100644 --- a/apps/web/src/components/dashboard/PackageRegistryAccessGate.tsx +++ b/apps/web/src/components/dashboard/PackageRegistryAccessGate.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router'; +import { Icon } from '@/components/ui/Icon'; import { YucpButton } from '@/components/ui/YucpButton'; interface PackageRegistryAccessGateProps { @@ -25,11 +26,7 @@ export function PackageRegistryAccessGate({
- +

{title}

diff --git a/apps/web/src/components/dashboard/PackageRegistryPanel.tsx b/apps/web/src/components/dashboard/PackageRegistryPanel.tsx index 4fcb922b3..9b32b97a5 100644 --- a/apps/web/src/components/dashboard/PackageRegistryPanel.tsx +++ b/apps/web/src/components/dashboard/PackageRegistryPanel.tsx @@ -1,5 +1,7 @@ import { Button, Card, Chip, ListBox, Select, Skeleton } from '@heroui/react'; -import { DropZone, EmptyState, Sheet } from '@heroui-pro/react'; +import { DropZone } from '@heroui-pro/react/drop-zone'; +import { EmptyState } from '@heroui-pro/react/empty-state'; +import { Sheet } from '@heroui-pro/react/sheet'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { AccountInlineError } from '@/components/account/AccountPage'; diff --git a/apps/web/src/components/dashboard/cards/StatCard.tsx b/apps/web/src/components/dashboard/cards/StatCard.tsx index bf8699fb6..d80a16d5b 100644 --- a/apps/web/src/components/dashboard/cards/StatCard.tsx +++ b/apps/web/src/components/dashboard/cards/StatCard.tsx @@ -1,4 +1,5 @@ import { type ReactNode } from 'react'; +import { Icon } from '@/components/ui/Icon'; export interface StatCardProps { label: string; @@ -17,38 +18,10 @@ export interface StatCardProps { function TrendArrow({ direction }: { direction: 'up' | 'down' | 'neutral' }) { if (direction === 'neutral') { - return ( - - ); + return ; } - return ( - - ); + return ; } export function StatCard({ diff --git a/apps/web/src/components/dashboard/panels/DangerZonePanel.tsx b/apps/web/src/components/dashboard/panels/DangerZonePanel.tsx index 74661dce0..b9ec35998 100644 --- a/apps/web/src/components/dashboard/panels/DangerZonePanel.tsx +++ b/apps/web/src/components/dashboard/panels/DangerZonePanel.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useState } from 'react'; +import { Icon } from '@/components/ui/Icon'; import { useToast } from '@/components/ui/Toast'; import { YucpButton } from '@/components/ui/YucpButton'; import { uninstallGuild } from '@/lib/dashboard'; @@ -77,63 +78,15 @@ const ACCENT: Record< // ─── Icons ───────────────────────────────────────────────────────────────────── function TrashIcon() { - return ( - - ); + return ; } function AlertTriangleIcon({ className }: { className?: string }) { - return ( - - ); + return ; } function LockIcon({ className }: { className?: string }) { - return ( - - ); + return ; } function StepIcons({ step }: { step: 1 | 2 | 3 }) { @@ -146,25 +99,7 @@ function StepIcons({ step }: { step: 1 | 2 | 3 }) { return ; } if (step === 2) { - return ( - - ); + return ; } return ; } diff --git a/apps/web/src/components/dashboard/panels/OnboardingProgressPanel.tsx b/apps/web/src/components/dashboard/panels/OnboardingProgressPanel.tsx index f005e382c..36aa3b6fa 100644 --- a/apps/web/src/components/dashboard/panels/OnboardingProgressPanel.tsx +++ b/apps/web/src/components/dashboard/panels/OnboardingProgressPanel.tsx @@ -1,5 +1,6 @@ import { ProgressBar } from '@heroui/react'; import { useMemo } from 'react'; +import { Icon } from '@/components/ui/Icon'; interface OnboardingStep { id: string; @@ -31,19 +32,7 @@ function CheckCircle({ completed }: { completed: boolean }) { }} aria-hidden="true" > - +
); } @@ -133,19 +122,7 @@ export function OnboardingProgressPanel({ steps, onDismiss }: OnboardingProgress className="onboarding-dismiss-btn" aria-label="Dismiss getting started checklist" > - + )}
diff --git a/apps/web/src/components/dashboard/panels/ServerSettingsPanel.tsx b/apps/web/src/components/dashboard/panels/ServerSettingsPanel.tsx index 844d6e0ff..67db38a59 100644 --- a/apps/web/src/components/dashboard/panels/ServerSettingsPanel.tsx +++ b/apps/web/src/components/dashboard/panels/ServerSettingsPanel.tsx @@ -4,9 +4,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { DashboardSkeletonSwap } from '@/components/dashboard/DashboardSkeletonSwap'; import { DashboardSettingsSkeleton } from '@/components/dashboard/DashboardSkeletons'; import { DashboardPanelErrorState } from '@/components/dashboard/PanelErrorState'; +import { Icon } from '@/components/ui/Icon'; import { Select } from '@/components/ui/Select'; import { useToast } from '@/components/ui/Toast'; import { isDashboardAuthError } from '@/hooks/useDashboardSession'; +import type { IconName } from '@/icons/manifest'; import type { DashboardGuildChannel, DashboardPolicy, DashboardSettingKey } from '@/lib/dashboard'; import { getDashboardSettings, listGuildChannels, updateDashboardSetting } from '@/lib/dashboard'; import { @@ -58,25 +60,25 @@ const SWITCH_SETTING_CONFIG = [ key: 'allowMismatchedEmails', label: 'Allow Mismatched Emails', hint: 'Verify with a different email than Discord.', - icon: '/Icons/World.png', + icon: 'globe', }, { key: 'autoVerifyOnJoin', label: 'Auto-Verify on Join', hint: 'Automatically verify members when they join the server.', - icon: '/Icons/Refresh.png', + icon: 'refresh', }, { key: 'shareVerificationWithServers', label: 'Share Across Servers', hint: 'Same Discord account, different servers. Verification carries over.', - icon: '/Icons/Link.png', + icon: 'link', }, { key: 'enableDiscordRoleFromOtherServers', label: 'Cross-Server Role Checks', hint: 'Check roles from servers the user is in.', - icon: '/Icons/PersonKey.png', + icon: 'userKey', }, ] as const satisfies ReadonlyArray<{ key: Extract< @@ -88,7 +90,7 @@ const SWITCH_SETTING_CONFIG = [ >; label: string; hint: string; - icon: string; + icon: IconName; }>; const SELECT_SETTING_CONFIG = [ @@ -96,7 +98,7 @@ const SELECT_SETTING_CONFIG = [ key: 'verificationScope', label: 'Verification Scope', hint: 'How verifications are scoped for buyers.', - icon: '/Icons/Key.png', + icon: 'key', options: [ { value: 'account', label: 'Account' }, { value: 'license', label: 'License' }, @@ -106,7 +108,7 @@ const SELECT_SETTING_CONFIG = [ key: 'duplicateVerificationBehavior', label: 'Duplicate Verifications', hint: 'What happens when a user verifies twice.', - icon: '/Icons/ClapStars.png', + icon: 'refresh', options: [ { value: 'allow', label: 'Allow' }, { value: 'notify', label: 'Notify' }, @@ -117,7 +119,7 @@ const SELECT_SETTING_CONFIG = [ key: 'suspiciousAccountBehavior', label: 'Suspicious Accounts', hint: 'How to handle potentially fraudulent accounts.', - icon: '/Icons/X.png', + icon: 'alert', options: [ { value: 'notify', label: 'Notify' }, { value: 'quarantine', label: 'Quarantine' }, @@ -131,7 +133,7 @@ const SELECT_SETTING_CONFIG = [ >; label: string; hint: string; - icon: string; + icon: IconName; options: ReadonlyArray<{ value: string; label: string }>; }>; @@ -140,15 +142,20 @@ const CHANNEL_SETTINGS = [ key: 'logChannelId' as const, label: 'Logs Channel', hint: 'Channel where verification activity logs are posted.', - icon: '/Icons/Library.png', + icon: 'auditLog', }, { key: 'announcementsChannelId' as const, label: 'Announcements Channel', hint: 'Channel where bot updates and announcements are posted.', - icon: '/Icons/World.png', + icon: 'bell', }, -]; +] as const satisfies ReadonlyArray<{ + key: Extract; + label: string; + hint: string; + icon: IconName; +}>; const SETTING_LABELS: Record = { allowMismatchedEmails: 'Allow mismatched emails', @@ -192,21 +199,7 @@ function SaveIndicator({ settingKey, state }: { settingKey: string; state: SaveI data-for={settingKey} aria-live="polite" > - + ); @@ -271,7 +250,7 @@ function SettingRow({ control, indicator, }: { - icon: string; + icon: IconName; label: string; hint: string; control: React.ReactNode; @@ -280,7 +259,7 @@ function SettingRow({ return (
- +
{label} @@ -492,7 +471,7 @@ export function ServerSettingsPanel({
- +

General Settings

diff --git a/apps/web/src/components/dashboard/panels/StatsOverviewPanel.tsx b/apps/web/src/components/dashboard/panels/StatsOverviewPanel.tsx index fd0f13ad6..c10043d1b 100644 --- a/apps/web/src/components/dashboard/panels/StatsOverviewPanel.tsx +++ b/apps/web/src/components/dashboard/panels/StatsOverviewPanel.tsx @@ -1,7 +1,7 @@ import { useQuery as useConvexQuery } from 'convex/react'; -import { Activity, KeyRound, LayoutGrid, ShieldCheck, Users } from 'lucide-react'; import type { ReactNode } from 'react'; import { StatCard } from '@/components/dashboard/cards/StatCard'; +import { Icon } from '@/components/ui/Icon'; import { api } from '../../../../../../convex/_generated/api'; interface DashboardStats { @@ -44,7 +44,7 @@ const STAT_DEFINITIONS: ReadonlyArray<{ { key: 'verified-members', label: 'Verified members', - icon: , + icon: , getValue: (s) => s.totalVerified, getTrend: (s) => s.recent24h > 0 @@ -57,7 +57,7 @@ const STAT_DEFINITIONS: ReadonlyArray<{ { key: 'active-products', label: 'Active products', - icon: , + icon: , getValue: (s) => s.totalProducts, loadingTrendRow: false, getHint: () => 'Listed SKUs you can verify against', @@ -65,7 +65,7 @@ const STAT_DEFINITIONS: ReadonlyArray<{ { key: 'verifications-7d', label: 'Verifications (7d)', - icon: , + icon: , getValue: (s) => s.recent7d, getTrend: (s) => ({ direction: s.recent24h > 0 ? ('up' as const) : ('neutral' as const), @@ -78,7 +78,7 @@ const STAT_DEFINITIONS: ReadonlyArray<{ { key: 'active-licenses', label: 'Active licenses', - icon: , + icon: , getValue: (s) => s.activeLicenses, getTrend: (s) => ({ direction: 'neutral' as const, @@ -112,7 +112,7 @@ export function StatsOverviewPanel() { >
- +

Verification metrics

diff --git a/apps/web/src/components/dashboard/panels/StoreIntegrationsPanel.tsx b/apps/web/src/components/dashboard/panels/StoreIntegrationsPanel.tsx index 460bfa941..0acd87e1c 100644 --- a/apps/web/src/components/dashboard/panels/StoreIntegrationsPanel.tsx +++ b/apps/web/src/components/dashboard/panels/StoreIntegrationsPanel.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; import { DashboardSkeletonSwap } from '@/components/dashboard/DashboardSkeletonSwap'; import { DashboardListSkeleton } from '@/components/dashboard/DashboardSkeletons'; +import { Icon } from '@/components/ui/Icon'; import { isDashboardAuthError } from '@/hooks/useDashboardSession'; import type { UserAccountConnection } from '@/lib/dashboard'; import { @@ -65,19 +66,7 @@ export function StoreRow({ {manageHref ? ( Configure - + ) : null}
@@ -177,21 +166,7 @@ export function StoreIntegrationsPanel({ }> {linkedProviders.length === 0 ? (
- +

No stores connected yet. Link a storefront in Connected Platforms on your personal dashboard. diff --git a/apps/web/src/components/ui/Icon.tsx b/apps/web/src/components/ui/Icon.tsx index ef1787b01..261cb52f6 100644 --- a/apps/web/src/components/ui/Icon.tsx +++ b/apps/web/src/components/ui/Icon.tsx @@ -1,34 +1,55 @@ -import type { SVGProps } from 'react'; +import type { CSSProperties, SVGProps } from 'react'; import { generatedIcons } from '@/icons/generated'; import type { IconName } from '@/icons/manifest'; +type IconStyle = CSSProperties & { + '--icon-theme-accent-color'?: string; +}; + export interface IconProps extends Omit< SVGProps, 'aria-label' | 'children' | 'dangerouslySetInnerHTML' | 'height' | 'name' | 'width' > { + accentColor?: string; + colorOnInteraction?: boolean; label?: string; name: IconName; size?: number | string; } -export function Icon({ className, label, name, size = 20, ...props }: IconProps) { +export function Icon({ + accentColor, + className, + colorOnInteraction = true, + label, + name, + size = 20, + style, + ...props +}: IconProps) { const icon = generatedIcons[name]; if (!icon) { throw new Error(`Unknown icon name: ${name}`); } const accessibleLabel = label?.trim() || undefined; + const iconStyle: IconStyle = { + ...style, + ...(accentColor ? { '--icon-theme-accent-color': accentColor } : {}), + }; return ( ( {selectedOption ? selectedOption.label : ''} - +

@@ -127,23 +114,7 @@ export function Select({ id, value, options, onChange, disabled, className }: Se }} > {opt.label}
diff --git a/apps/web/src/components/ui/Toast.tsx b/apps/web/src/components/ui/Toast.tsx index d9ed398bd..2d1d78ba3 100644 --- a/apps/web/src/components/ui/Toast.tsx +++ b/apps/web/src/components/ui/Toast.tsx @@ -7,6 +7,7 @@ import { useRef, useState, } from 'react'; +import { Icon } from '@/components/ui/Icon'; export type ToastType = 'success' | 'error' | 'warning' | 'info'; @@ -186,19 +187,7 @@ function ToastItemComponent({ aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)} > - + {toast.duration > 0 ? (