From a48a59b08a6ca1ee742e8ec93cd05841d2aaad02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:32:03 +0000 Subject: [PATCH 1/3] Initial plan From ba516b250153c510fe0213c7e75e4bc6a8df57b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:40:44 +0000 Subject: [PATCH 2/3] feat(cloud): add developer-portal, marketplace-admin, and app-store protocols Three new protocol files covering the complete marketplace ecosystem: - Developer Portal: account management, API keys, release channels, listing CRUD, analytics - Marketplace Admin: review workflow, curation, governance, platform health metrics - App Store (Customer): reviews/ratings, discovery/recommendations, subscriptions, installed apps Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/cloud/app-store.test.ts | 390 +++++++++++++++++ packages/spec/src/cloud/app-store.zod.ts | 396 ++++++++++++++++++ .../spec/src/cloud/developer-portal.test.ts | 317 ++++++++++++++ .../spec/src/cloud/developer-portal.zod.ts | 369 ++++++++++++++++ packages/spec/src/cloud/index.ts | 8 +- .../spec/src/cloud/marketplace-admin.test.ts | 299 +++++++++++++ .../spec/src/cloud/marketplace-admin.zod.ts | 311 ++++++++++++++ 7 files changed, 2089 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/cloud/app-store.test.ts create mode 100644 packages/spec/src/cloud/app-store.zod.ts create mode 100644 packages/spec/src/cloud/developer-portal.test.ts create mode 100644 packages/spec/src/cloud/developer-portal.zod.ts create mode 100644 packages/spec/src/cloud/marketplace-admin.test.ts create mode 100644 packages/spec/src/cloud/marketplace-admin.zod.ts diff --git a/packages/spec/src/cloud/app-store.test.ts b/packages/spec/src/cloud/app-store.test.ts new file mode 100644 index 000000000..421d4614d --- /dev/null +++ b/packages/spec/src/cloud/app-store.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect } from 'vitest'; +import { + ReviewModerationStatusSchema, + UserReviewSchema, + SubmitReviewRequestSchema, + ListReviewsRequestSchema, + ListReviewsResponseSchema, + RecommendationReasonSchema, + RecommendedAppSchema, + AppDiscoveryRequestSchema, + AppDiscoveryResponseSchema, + SubscriptionStatusSchema, + AppSubscriptionSchema, + InstalledAppSummarySchema, + ListInstalledAppsRequestSchema, + ListInstalledAppsResponseSchema, +} from './app-store.zod'; + +describe('ReviewModerationStatusSchema', () => { + it('should accept all moderation statuses', () => { + const statuses = ['pending', 'approved', 'flagged', 'rejected']; + statuses.forEach(status => { + expect(() => ReviewModerationStatusSchema.parse(status)).not.toThrow(); + }); + }); +}); + +describe('UserReviewSchema', () => { + it('should accept minimal review (rating only)', () => { + const review = { + id: 'rev-001', + listingId: 'listing-001', + userId: 'user-001', + rating: 5, + submittedAt: '2025-06-01T10:00:00Z', + }; + const parsed = UserReviewSchema.parse(review); + expect(parsed.moderationStatus).toBe('pending'); + expect(parsed.helpfulCount).toBe(0); + }); + + it('should accept full review with publisher response', () => { + const review = { + id: 'rev-001', + listingId: 'listing-001', + userId: 'user-001', + displayName: 'John Doe', + rating: 4, + title: 'Great CRM plugin!', + body: 'This plugin transformed our sales process. The pipeline view is excellent.', + appVersion: '2.1.0', + moderationStatus: 'approved' as const, + helpfulCount: 12, + publisherResponse: { + body: 'Thank you for the kind review! We are glad you enjoy the pipeline view.', + respondedAt: '2025-06-02T14:00:00Z', + }, + submittedAt: '2025-06-01T10:00:00Z', + updatedAt: '2025-06-02T14:00:00Z', + }; + const parsed = UserReviewSchema.parse(review); + expect(parsed.publisherResponse?.body).toContain('Thank you'); + expect(parsed.helpfulCount).toBe(12); + }); + + it('should enforce rating range 1-5', () => { + const base = { + id: 'rev-001', + listingId: 'listing-001', + userId: 'user-001', + submittedAt: '2025-06-01T10:00:00Z', + }; + expect(() => UserReviewSchema.parse({ ...base, rating: 0 })).toThrow(); + expect(() => UserReviewSchema.parse({ ...base, rating: 6 })).toThrow(); + expect(() => UserReviewSchema.parse({ ...base, rating: 1 })).not.toThrow(); + expect(() => UserReviewSchema.parse({ ...base, rating: 5 })).not.toThrow(); + }); + + it('should enforce title max length', () => { + const review = { + id: 'rev-001', + listingId: 'listing-001', + userId: 'user-001', + rating: 3, + title: 'x'.repeat(201), + submittedAt: '2025-06-01T10:00:00Z', + }; + expect(() => UserReviewSchema.parse(review)).toThrow(); + }); + + it('should enforce body max length', () => { + const review = { + id: 'rev-001', + listingId: 'listing-001', + userId: 'user-001', + rating: 3, + body: 'x'.repeat(5001), + submittedAt: '2025-06-01T10:00:00Z', + }; + expect(() => UserReviewSchema.parse(review)).toThrow(); + }); +}); + +describe('SubmitReviewRequestSchema', () => { + it('should accept minimal review submission', () => { + const request = { + listingId: 'listing-001', + rating: 5, + }; + const parsed = SubmitReviewRequestSchema.parse(request); + expect(parsed.rating).toBe(5); + }); + + it('should accept review with title and body', () => { + const request = { + listingId: 'listing-001', + rating: 4, + title: 'Great app', + body: 'Works perfectly for our needs.', + }; + const parsed = SubmitReviewRequestSchema.parse(request); + expect(parsed.title).toBe('Great app'); + }); +}); + +describe('ListReviewsRequestSchema', () => { + it('should accept minimal request', () => { + const request = { listingId: 'listing-001' }; + const parsed = ListReviewsRequestSchema.parse(request); + expect(parsed.sortBy).toBe('newest'); + expect(parsed.page).toBe(1); + expect(parsed.pageSize).toBe(10); + }); + + it('should accept filtered request', () => { + const request = { + listingId: 'listing-001', + sortBy: 'most-helpful' as const, + rating: 5, + page: 2, + pageSize: 25, + }; + const parsed = ListReviewsRequestSchema.parse(request); + expect(parsed.rating).toBe(5); + }); + + it('should reject invalid page size', () => { + expect(() => ListReviewsRequestSchema.parse({ + listingId: 'listing-001', + pageSize: 0, + })).toThrow(); + expect(() => ListReviewsRequestSchema.parse({ + listingId: 'listing-001', + pageSize: 51, + })).toThrow(); + }); +}); + +describe('ListReviewsResponseSchema', () => { + it('should accept empty response', () => { + const response = { + items: [], + total: 0, + page: 1, + pageSize: 10, + }; + const parsed = ListReviewsResponseSchema.parse(response); + expect(parsed.items).toHaveLength(0); + }); + + it('should accept response with rating summary', () => { + const response = { + items: [{ + id: 'rev-001', + listingId: 'listing-001', + userId: 'user-001', + rating: 5, + submittedAt: '2025-06-01T10:00:00Z', + }], + total: 1, + page: 1, + pageSize: 10, + ratingSummary: { + averageRating: 4.5, + totalRatings: 120, + distribution: { 1: 2, 2: 5, 3: 15, 4: 48, 5: 50 }, + }, + }; + const parsed = ListReviewsResponseSchema.parse(response); + expect(parsed.ratingSummary?.averageRating).toBe(4.5); + expect(parsed.ratingSummary?.distribution[5]).toBe(50); + }); +}); + +describe('RecommendationReasonSchema', () => { + it('should accept all recommendation reasons', () => { + const reasons = [ + 'popular-in-category', 'similar-users', 'complements-installed', + 'trending', 'new-release', 'editor-pick', + ]; + reasons.forEach(reason => { + expect(() => RecommendationReasonSchema.parse(reason)).not.toThrow(); + }); + }); +}); + +describe('RecommendedAppSchema', () => { + it('should accept recommended app', () => { + const app = { + listingId: 'listing-001', + name: 'Acme CRM', + tagline: 'Complete CRM for ObjectStack', + iconUrl: 'https://acme.com/icon.png', + category: 'crm' as const, + pricing: 'freemium' as const, + averageRating: 4.5, + activeInstalls: 3200, + reason: 'popular-in-category' as const, + }; + const parsed = RecommendedAppSchema.parse(app); + expect(parsed.reason).toBe('popular-in-category'); + }); +}); + +describe('AppDiscoveryRequestSchema', () => { + it('should accept empty discovery request', () => { + const parsed = AppDiscoveryRequestSchema.parse({}); + expect(parsed.limit).toBe(10); + }); + + it('should accept personalized discovery request', () => { + const request = { + tenantId: 'tenant-001', + categories: ['crm', 'analytics'], + platformVersion: '1.5.0', + limit: 20, + }; + const parsed = AppDiscoveryRequestSchema.parse(request); + expect(parsed.categories).toHaveLength(2); + }); +}); + +describe('AppDiscoveryResponseSchema', () => { + it('should accept full discovery response', () => { + const app = { + listingId: 'listing-001', + name: 'Acme CRM', + category: 'crm' as const, + pricing: 'free' as const, + reason: 'editor-pick' as const, + }; + const response = { + featured: [app], + recommended: [{ ...app, reason: 'popular-in-category' as const }], + trending: [{ ...app, reason: 'trending' as const }], + newArrivals: [{ ...app, reason: 'new-release' as const }], + collections: [{ + id: 'col-001', + name: 'Best for Small Business', + apps: [app], + }], + }; + const parsed = AppDiscoveryResponseSchema.parse(response); + expect(parsed.featured).toHaveLength(1); + expect(parsed.collections).toHaveLength(1); + }); + + it('should accept minimal discovery response', () => { + const response = {}; + const parsed = AppDiscoveryResponseSchema.parse(response); + expect(parsed.featured).toBeUndefined(); + }); +}); + +describe('SubscriptionStatusSchema', () => { + it('should accept all subscription statuses', () => { + const statuses = ['active', 'trialing', 'past-due', 'cancelled', 'expired']; + statuses.forEach(status => { + expect(() => SubscriptionStatusSchema.parse(status)).not.toThrow(); + }); + }); +}); + +describe('AppSubscriptionSchema', () => { + it('should accept active subscription', () => { + const subscription = { + id: 'sub-001', + listingId: 'listing-001', + tenantId: 'tenant-001', + status: 'active' as const, + plan: 'Professional', + billingCycle: 'annual' as const, + priceInCents: 11988, + currentPeriodStart: '2025-01-01T00:00:00Z', + currentPeriodEnd: '2026-01-01T00:00:00Z', + autoRenew: true, + createdAt: '2025-01-01T00:00:00Z', + }; + const parsed = AppSubscriptionSchema.parse(subscription); + expect(parsed.status).toBe('active'); + expect(parsed.autoRenew).toBe(true); + }); + + it('should accept trial subscription', () => { + const subscription = { + id: 'sub-002', + listingId: 'listing-001', + tenantId: 'tenant-001', + status: 'trialing' as const, + trialEndDate: '2025-07-01T00:00:00Z', + createdAt: '2025-06-01T00:00:00Z', + }; + const parsed = AppSubscriptionSchema.parse(subscription); + expect(parsed.status).toBe('trialing'); + expect(parsed.trialEndDate).toBe('2025-07-01T00:00:00Z'); + }); +}); + +describe('InstalledAppSummarySchema', () => { + it('should accept installed app with update available', () => { + const app = { + listingId: 'listing-001', + packageId: 'com.acme.crm', + name: 'Acme CRM', + iconUrl: 'https://acme.com/icon.png', + installedVersion: '2.0.0', + latestVersion: '2.1.0', + updateAvailable: true, + enabled: true, + subscriptionStatus: 'active' as const, + installedAt: '2025-03-01T00:00:00Z', + }; + const parsed = InstalledAppSummarySchema.parse(app); + expect(parsed.updateAvailable).toBe(true); + expect(parsed.subscriptionStatus).toBe('active'); + }); + + it('should accept minimal installed app', () => { + const app = { + listingId: 'listing-002', + packageId: 'com.acme.utils', + name: 'Acme Utils', + installedVersion: '1.0.0', + installedAt: '2025-06-01T00:00:00Z', + }; + const parsed = InstalledAppSummarySchema.parse(app); + expect(parsed.updateAvailable).toBe(false); + expect(parsed.enabled).toBe(true); + }); +}); + +describe('ListInstalledAppsRequestSchema', () => { + it('should accept empty request', () => { + const parsed = ListInstalledAppsRequestSchema.parse({}); + expect(parsed.sortBy).toBe('name'); + expect(parsed.page).toBe(1); + expect(parsed.pageSize).toBe(20); + }); + + it('should accept filtered request', () => { + const request = { + tenantId: 'tenant-001', + enabled: true, + updateAvailable: true, + sortBy: 'installed-date' as const, + }; + const parsed = ListInstalledAppsRequestSchema.parse(request); + expect(parsed.updateAvailable).toBe(true); + }); +}); + +describe('ListInstalledAppsResponseSchema', () => { + it('should accept response with installed apps', () => { + const response = { + items: [{ + listingId: 'listing-001', + packageId: 'com.acme.crm', + name: 'Acme CRM', + installedVersion: '2.0.0', + installedAt: '2025-03-01T00:00:00Z', + }], + total: 1, + page: 1, + pageSize: 20, + }; + const parsed = ListInstalledAppsResponseSchema.parse(response); + expect(parsed.items).toHaveLength(1); + expect(parsed.total).toBe(1); + }); +}); diff --git a/packages/spec/src/cloud/app-store.zod.ts b/packages/spec/src/cloud/app-store.zod.ts new file mode 100644 index 000000000..54c7c97a7 --- /dev/null +++ b/packages/spec/src/cloud/app-store.zod.ts @@ -0,0 +1,396 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { MarketplaceCategorySchema, PricingModelSchema } from './marketplace.zod'; + +/** + * # App Store Protocol (Customer Experience) + * + * Defines schemas for the end-customer experience when browsing, evaluating, + * installing, and managing marketplace apps from within ObjectOS. + * + * ## Architecture Alignment + * - **Salesforce AppExchange (Customer)**: Browse apps, read reviews, 1-click install + * - **Shopify App Store (Merchant)**: App evaluation, trial, install, manage subscriptions + * - **Apple App Store (User)**: Ratings, reviews, featured collections, personalized recs + * + * ## Customer Journey + * ``` + * Discover → Evaluate → Install → Configure → Use → Rate/Review → Manage + * ``` + * + * ## Key Concepts + * - **Reviews & Ratings**: User-submitted ratings and reviews with moderation + * - **Collections & Recommendations**: Personalized discovery and curated picks + * - **Subscription Management**: Manage licenses, billing, and renewals + * - **Installed App Management**: Enable, disable, configure, upgrade, uninstall + */ + +// ========================================== +// User Reviews & Ratings +// ========================================== + +/** + * Review Moderation Status + */ +export const ReviewModerationStatusSchema = z.enum([ + 'pending', // Awaiting moderation + 'approved', // Approved and visible + 'flagged', // Flagged for review + 'rejected', // Rejected (spam, inappropriate) +]); + +/** + * User Review Schema — a customer's review of an installed app + */ +export const UserReviewSchema = z.object({ + /** Review ID */ + id: z.string().describe('Review ID'), + + /** Listing ID being reviewed */ + listingId: z.string().describe('Listing being reviewed'), + + /** Reviewer user ID */ + userId: z.string().describe('Review author user ID'), + + /** Reviewer display name */ + displayName: z.string().optional().describe('Reviewer display name'), + + /** Star rating (1-5) */ + rating: z.number().int().min(1).max(5).describe('Star rating (1-5)'), + + /** Review title */ + title: z.string().max(200).optional().describe('Review title'), + + /** Review body text */ + body: z.string().max(5000).optional().describe('Review text'), + + /** Version the reviewer is using */ + appVersion: z.string().optional().describe('App version being reviewed'), + + /** Moderation status */ + moderationStatus: ReviewModerationStatusSchema.default('pending'), + + /** Number of "helpful" votes from other users */ + helpfulCount: z.number().int().min(0).default(0), + + /** Publisher's response to this review */ + publisherResponse: z.object({ + body: z.string(), + respondedAt: z.string().datetime(), + }).optional().describe('Publisher response to review'), + + /** Submitted timestamp */ + submittedAt: z.string().datetime(), + + /** Updated timestamp */ + updatedAt: z.string().datetime().optional(), +}); + +/** + * Submit Review Request + */ +export const SubmitReviewRequestSchema = z.object({ + /** Listing ID */ + listingId: z.string().describe('Listing to review'), + + /** Star rating (1-5) */ + rating: z.number().int().min(1).max(5).describe('Star rating'), + + /** Review title */ + title: z.string().max(200).optional(), + + /** Review body */ + body: z.string().max(5000).optional(), +}); + +/** + * List Reviews Request — customer browsing reviews + */ +export const ListReviewsRequestSchema = z.object({ + /** Listing ID */ + listingId: z.string().describe('Listing to get reviews for'), + + /** Sort by */ + sortBy: z.enum(['newest', 'oldest', 'highest', 'lowest', 'most-helpful']) + .default('newest'), + + /** Filter by rating */ + rating: z.number().int().min(1).max(5).optional(), + + /** Pagination */ + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(50).default(10), +}); + +/** + * List Reviews Response + */ +export const ListReviewsResponseSchema = z.object({ + /** Reviews */ + items: z.array(UserReviewSchema), + + /** Total count */ + total: z.number().int().min(0), + + /** Pagination */ + page: z.number().int().min(1), + pageSize: z.number().int().min(1), + + /** Rating summary */ + ratingSummary: z.object({ + averageRating: z.number().min(0).max(5), + totalRatings: z.number().int().min(0), + distribution: z.object({ + 1: z.number().int().min(0).default(0), + 2: z.number().int().min(0).default(0), + 3: z.number().int().min(0).default(0), + 4: z.number().int().min(0).default(0), + 5: z.number().int().min(0).default(0), + }), + }).optional(), +}); + +// ========================================== +// App Discovery & Recommendations +// ========================================== + +/** + * App Recommendation Reason + */ +export const RecommendationReasonSchema = z.enum([ + 'popular-in-category', // Popular in your industry/category + 'similar-users', // Used by similar organizations + 'complements-installed', // Complements apps you already use + 'trending', // Currently trending + 'new-release', // Recently released / major update + 'editor-pick', // Editorial/curated recommendation +]); + +/** + * Recommended App + */ +export const RecommendedAppSchema = z.object({ + /** Listing ID */ + listingId: z.string(), + + /** App name */ + name: z.string(), + + /** Short tagline */ + tagline: z.string().optional(), + + /** Icon URL */ + iconUrl: z.string().url().optional(), + + /** Category */ + category: MarketplaceCategorySchema, + + /** Pricing */ + pricing: PricingModelSchema, + + /** Average rating */ + averageRating: z.number().min(0).max(5).optional(), + + /** Active installs */ + activeInstalls: z.number().int().min(0).optional(), + + /** Why this is recommended */ + reason: RecommendationReasonSchema, +}); + +/** + * App Discovery Request — personalized browse/home page + */ +export const AppDiscoveryRequestSchema = z.object({ + /** Tenant ID for personalization */ + tenantId: z.string().optional(), + + /** Categories the customer is interested in */ + categories: z.array(MarketplaceCategorySchema).optional(), + + /** Platform version for compatibility filtering */ + platformVersion: z.string().optional(), + + /** Max number of items per section */ + limit: z.number().int().min(1).max(50).default(10), +}); + +/** + * App Discovery Response — structured content for the storefront + */ +export const AppDiscoveryResponseSchema = z.object({ + /** Featured apps (editorial picks) */ + featured: z.array(RecommendedAppSchema).optional(), + + /** Personalized recommendations */ + recommended: z.array(RecommendedAppSchema).optional(), + + /** Trending apps */ + trending: z.array(RecommendedAppSchema).optional(), + + /** Recently added */ + newArrivals: z.array(RecommendedAppSchema).optional(), + + /** Curated collections */ + collections: z.array(z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + coverImageUrl: z.string().url().optional(), + apps: z.array(RecommendedAppSchema), + })).optional(), +}); + +// ========================================== +// Subscription & License Management +// ========================================== + +/** + * Subscription Status + */ +export const SubscriptionStatusSchema = z.enum([ + 'active', // Active and paid + 'trialing', // Free trial period + 'past-due', // Payment overdue + 'cancelled', // Cancelled (still active until period ends) + 'expired', // Expired / ended +]); + +/** + * App Subscription Schema — customer's license/subscription for an app + */ +export const AppSubscriptionSchema = z.object({ + /** Subscription ID */ + id: z.string().describe('Subscription ID'), + + /** Listing ID */ + listingId: z.string().describe('App listing ID'), + + /** Tenant ID */ + tenantId: z.string().describe('Customer tenant ID'), + + /** Subscription status */ + status: SubscriptionStatusSchema, + + /** License key */ + licenseKey: z.string().optional(), + + /** Plan/tier name (if multiple plans) */ + plan: z.string().optional().describe('Subscription plan name'), + + /** Billing cycle */ + billingCycle: z.enum(['monthly', 'annual']).optional(), + + /** Price per billing cycle (in cents) */ + priceInCents: z.number().int().min(0).optional(), + + /** Current period start */ + currentPeriodStart: z.string().datetime().optional(), + + /** Current period end */ + currentPeriodEnd: z.string().datetime().optional(), + + /** Trial end date (if trialing) */ + trialEndDate: z.string().datetime().optional(), + + /** Whether auto-renew is on */ + autoRenew: z.boolean().default(true), + + /** Created timestamp */ + createdAt: z.string().datetime(), +}); + +// ========================================== +// Installed App Management (Customer Side) +// ========================================== + +/** + * Installed App Summary — what the customer sees in their "My Apps" dashboard + */ +export const InstalledAppSummarySchema = z.object({ + /** Listing ID */ + listingId: z.string(), + + /** Package ID */ + packageId: z.string(), + + /** Display name */ + name: z.string(), + + /** Icon URL */ + iconUrl: z.string().url().optional(), + + /** Installed version */ + installedVersion: z.string(), + + /** Latest available version */ + latestVersion: z.string().optional(), + + /** Whether an update is available */ + updateAvailable: z.boolean().default(false), + + /** Whether the app is currently enabled */ + enabled: z.boolean().default(true), + + /** Subscription status (for paid apps) */ + subscriptionStatus: SubscriptionStatusSchema.optional(), + + /** Installed timestamp */ + installedAt: z.string().datetime(), +}); + +/** + * List Installed Apps Request + */ +export const ListInstalledAppsRequestSchema = z.object({ + /** Tenant ID */ + tenantId: z.string().optional(), + + /** Filter by enabled/disabled */ + enabled: z.boolean().optional(), + + /** Filter by update availability */ + updateAvailable: z.boolean().optional(), + + /** Sort by */ + sortBy: z.enum(['name', 'installed-date', 'updated-date']).default('name'), + + /** Pagination */ + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(100).default(20), +}); + +/** + * List Installed Apps Response + */ +export const ListInstalledAppsResponseSchema = z.object({ + /** Installed apps */ + items: z.array(InstalledAppSummarySchema), + + /** Total count */ + total: z.number().int().min(0), + + /** Pagination */ + page: z.number().int().min(1), + pageSize: z.number().int().min(1), +}); + +// ========================================== +// Export Types +// ========================================== + +export type ReviewModerationStatus = z.infer; +export type UserReview = z.infer; +export type SubmitReviewRequest = z.infer; +export type ListReviewsRequest = z.infer; +export type ListReviewsResponse = z.infer; +export type RecommendationReason = z.infer; +export type RecommendedApp = z.infer; +export type AppDiscoveryRequest = z.infer; +export type AppDiscoveryResponse = z.infer; +export type SubscriptionStatus = z.infer; +export type AppSubscription = z.infer; +export type InstalledAppSummary = z.infer; +export type ListInstalledAppsRequest = z.infer; +export type ListInstalledAppsResponse = z.infer; diff --git a/packages/spec/src/cloud/developer-portal.test.ts b/packages/spec/src/cloud/developer-portal.test.ts new file mode 100644 index 000000000..fcb7bb71a --- /dev/null +++ b/packages/spec/src/cloud/developer-portal.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest'; +import { + DeveloperAccountStatusSchema, + ApiKeyScopeSchema, + DeveloperApiKeySchema, + DeveloperAccountSchema, + ReleaseChannelSchema, + VersionReleaseSchema, + CreateListingRequestSchema, + UpdateListingRequestSchema, + ListingActionRequestSchema, + AnalyticsTimeRangeSchema, + PublishingAnalyticsRequestSchema, + PublishingAnalyticsResponseSchema, + TimeSeriesPointSchema, +} from './developer-portal.zod'; + +describe('DeveloperAccountStatusSchema', () => { + it('should accept all valid statuses', () => { + const statuses = ['pending', 'active', 'suspended', 'deactivated']; + statuses.forEach(status => { + expect(() => DeveloperAccountStatusSchema.parse(status)).not.toThrow(); + }); + }); + + it('should reject invalid status', () => { + expect(() => DeveloperAccountStatusSchema.parse('banned')).toThrow(); + }); +}); + +describe('ApiKeyScopeSchema', () => { + it('should accept all valid scopes', () => { + const scopes = ['publish', 'read', 'manage', 'admin']; + scopes.forEach(scope => { + expect(() => ApiKeyScopeSchema.parse(scope)).not.toThrow(); + }); + }); +}); + +describe('DeveloperApiKeySchema', () => { + it('should accept minimal API key', () => { + const key = { + id: 'key-001', + label: 'CI/CD Pipeline', + scopes: ['publish'], + createdAt: '2025-06-01T00:00:00Z', + }; + const parsed = DeveloperApiKeySchema.parse(key); + expect(parsed.active).toBe(true); + }); + + it('should accept full API key', () => { + const key = { + id: 'key-001', + label: 'CI/CD Pipeline', + scopes: ['publish', 'read'], + prefix: 'os_pk_ab', + expiresAt: '2026-06-01T00:00:00Z', + createdAt: '2025-06-01T00:00:00Z', + lastUsedAt: '2025-09-15T10:30:00Z', + active: true, + }; + const parsed = DeveloperApiKeySchema.parse(key); + expect(parsed.scopes).toHaveLength(2); + expect(parsed.prefix).toBe('os_pk_ab'); + }); + + it('should require at least one scope', () => { + const key = { + id: 'key-001', + label: 'Empty', + scopes: [], + createdAt: '2025-06-01T00:00:00Z', + }; + expect(() => DeveloperApiKeySchema.parse(key)).toThrow(); + }); +}); + +describe('DeveloperAccountSchema', () => { + it('should accept minimal account', () => { + const account = { + id: 'dev-001', + publisherId: 'pub-001', + organizationName: 'Acme Corp', + email: 'dev@acme.com', + registeredAt: '2025-01-15T10:00:00Z', + }; + const parsed = DeveloperAccountSchema.parse(account); + expect(parsed.status).toBe('pending'); + expect(parsed.verification).toBe('unverified'); + }); + + it('should accept full account with team members', () => { + const account = { + id: 'dev-001', + publisherId: 'pub-001', + status: 'active' as const, + verification: 'verified' as const, + organizationName: 'Acme Corp', + email: 'dev@acme.com', + teamMembers: [ + { userId: 'user-001', role: 'owner' as const, joinedAt: '2025-01-15T10:00:00Z' }, + { userId: 'user-002', role: 'developer' as const }, + ], + agreementVersion: '2.0', + registeredAt: '2025-01-15T10:00:00Z', + }; + const parsed = DeveloperAccountSchema.parse(account); + expect(parsed.teamMembers).toHaveLength(2); + expect(parsed.status).toBe('active'); + }); + + it('should require valid email', () => { + const account = { + id: 'dev-001', + publisherId: 'pub-001', + organizationName: 'Acme Corp', + email: 'not-an-email', + registeredAt: '2025-01-15T10:00:00Z', + }; + expect(() => DeveloperAccountSchema.parse(account)).toThrow(); + }); +}); + +describe('ReleaseChannelSchema', () => { + it('should accept all channels', () => { + const channels = ['alpha', 'beta', 'rc', 'stable']; + channels.forEach(channel => { + expect(() => ReleaseChannelSchema.parse(channel)).not.toThrow(); + }); + }); +}); + +describe('VersionReleaseSchema', () => { + it('should accept minimal release', () => { + const release = { + version: '1.0.0', + }; + const parsed = VersionReleaseSchema.parse(release); + expect(parsed.channel).toBe('stable'); + expect(parsed.deprecated).toBe(false); + }); + + it('should accept beta release with changelog', () => { + const release = { + version: '2.0.0-beta.1', + channel: 'beta' as const, + releaseNotes: '## Beta Release\n\nNew pipeline view', + changelog: [ + { type: 'added' as const, description: 'New pipeline view' }, + { type: 'fixed' as const, description: 'Fixed pagination bug' }, + ], + minPlatformVersion: '1.2.0', + artifactUrl: 'https://registry.objectstack.io/com.acme.crm-2.0.0-beta.1.tgz', + artifactChecksum: 'sha256:abc123...', + releasedAt: '2025-06-01T00:00:00Z', + }; + const parsed = VersionReleaseSchema.parse(release); + expect(parsed.channel).toBe('beta'); + expect(parsed.changelog).toHaveLength(2); + }); + + it('should accept deprecated release with message', () => { + const release = { + version: '1.0.0', + deprecated: true, + deprecationMessage: 'Please upgrade to v2.0.0', + }; + const parsed = VersionReleaseSchema.parse(release); + expect(parsed.deprecated).toBe(true); + expect(parsed.deprecationMessage).toBe('Please upgrade to v2.0.0'); + }); +}); + +describe('CreateListingRequestSchema', () => { + it('should accept minimal listing creation', () => { + const request = { + packageId: 'com.acme.crm', + name: 'Acme CRM', + category: 'crm', + }; + const parsed = CreateListingRequestSchema.parse(request); + expect(parsed.pricing).toBe('free'); + }); + + it('should accept full listing creation', () => { + const request = { + packageId: 'com.acme.crm', + name: 'Acme CRM', + tagline: 'Complete CRM for ObjectStack', + description: '# Acme CRM\n\nFull-featured...', + category: 'crm', + tags: ['sales', 'pipeline'], + iconUrl: 'https://acme.com/icon.png', + screenshots: [{ url: 'https://acme.com/s1.png', caption: 'Dashboard' }], + documentationUrl: 'https://docs.acme.com', + supportUrl: 'https://support.acme.com', + repositoryUrl: 'https://github.com/acme/crm', + pricing: 'freemium' as const, + priceInCents: 999, + }; + const parsed = CreateListingRequestSchema.parse(request); + expect(parsed.screenshots).toHaveLength(1); + }); + + it('should enforce tagline max length', () => { + const request = { + packageId: 'com.acme.crm', + name: 'Test', + category: 'other', + tagline: 'x'.repeat(121), + }; + expect(() => CreateListingRequestSchema.parse(request)).toThrow(); + }); +}); + +describe('UpdateListingRequestSchema', () => { + it('should accept partial update', () => { + const request = { + listingId: 'listing-001', + tagline: 'Updated tagline', + }; + const parsed = UpdateListingRequestSchema.parse(request); + expect(parsed.tagline).toBe('Updated tagline'); + expect(parsed.name).toBeUndefined(); + }); +}); + +describe('ListingActionRequestSchema', () => { + it('should accept all listing actions', () => { + const actions = ['submit', 'unlist', 'deprecate', 'reactivate']; + actions.forEach(action => { + const request = { listingId: 'listing-001', action }; + expect(() => ListingActionRequestSchema.parse(request)).not.toThrow(); + }); + }); + + it('should accept action with reason', () => { + const request = { + listingId: 'listing-001', + action: 'deprecate' as const, + reason: 'Replaced by com.acme.crm-v2', + }; + const parsed = ListingActionRequestSchema.parse(request); + expect(parsed.reason).toBe('Replaced by com.acme.crm-v2'); + }); +}); + +describe('AnalyticsTimeRangeSchema', () => { + it('should accept all time ranges', () => { + const ranges = ['last_7d', 'last_30d', 'last_90d', 'last_365d', 'all_time']; + ranges.forEach(range => { + expect(() => AnalyticsTimeRangeSchema.parse(range)).not.toThrow(); + }); + }); +}); + +describe('PublishingAnalyticsRequestSchema', () => { + it('should accept minimal request', () => { + const request = { listingId: 'listing-001' }; + const parsed = PublishingAnalyticsRequestSchema.parse(request); + expect(parsed.timeRange).toBe('last_30d'); + }); + + it('should accept request with specific metrics', () => { + const request = { + listingId: 'listing-001', + timeRange: 'last_90d' as const, + metrics: ['installs', 'ratings', 'revenue'], + }; + const parsed = PublishingAnalyticsRequestSchema.parse(request); + expect(parsed.metrics).toHaveLength(3); + }); +}); + +describe('TimeSeriesPointSchema', () => { + it('should accept data point', () => { + const point = { date: '2025-06-01', value: 42 }; + const parsed = TimeSeriesPointSchema.parse(point); + expect(parsed.value).toBe(42); + }); +}); + +describe('PublishingAnalyticsResponseSchema', () => { + it('should accept analytics response', () => { + const response = { + listingId: 'listing-001', + timeRange: 'last_30d' as const, + summary: { + totalInstalls: 5000, + activeInstalls: 3200, + totalUninstalls: 800, + averageRating: 4.5, + totalRatings: 120, + totalRevenue: 99900, + pageViews: 15000, + }, + timeSeries: { + installs: [ + { date: '2025-06-01', value: 45 }, + { date: '2025-06-02', value: 52 }, + ], + }, + ratingDistribution: { + 1: 2, + 2: 5, + 3: 15, + 4: 48, + 5: 50, + }, + }; + const parsed = PublishingAnalyticsResponseSchema.parse(response); + expect(parsed.summary.totalInstalls).toBe(5000); + expect(parsed.timeSeries?.installs).toHaveLength(2); + expect(parsed.ratingDistribution?.[5]).toBe(50); + }); +}); diff --git a/packages/spec/src/cloud/developer-portal.zod.ts b/packages/spec/src/cloud/developer-portal.zod.ts new file mode 100644 index 000000000..0ac4610be --- /dev/null +++ b/packages/spec/src/cloud/developer-portal.zod.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { PublisherVerificationSchema } from './marketplace.zod'; + +/** + * # Developer Portal Protocol + * + * Defines schemas for the developer-facing side of the marketplace ecosystem. + * Covers the complete developer journey: + * + * ``` + * Register → Create App → Develop → Validate → Build → Submit → Monitor → Iterate + * ``` + * + * ## Architecture Alignment + * - **Salesforce Partner Portal**: ISV registration, AppExchange publishing, Trialforce + * - **Shopify Partner Dashboard**: App management, analytics, billing + * - **VS Code Marketplace Management**: Extension publishing, statistics, tokens + * + * ## Key Concepts + * - **Developer Account**: Registration and API key management + * - **App Listing Management**: CRUD for marketplace listings (draft → published) + * - **Version Channels**: alpha / beta / rc / stable release channels + * - **Publishing Analytics**: Install trends, revenue, ratings over time + */ + +// ========================================== +// Developer Account & API Keys +// ========================================== + +/** + * Developer Account Status + */ +export const DeveloperAccountStatusSchema = z.enum([ + 'pending', // Registration submitted, awaiting approval + 'active', // Account active and can publish + 'suspended', // Temporarily suspended (policy violation) + 'deactivated', // Deactivated by developer +]); + +/** + * API Key Scope — controls what the key can do + */ +export const ApiKeyScopeSchema = z.enum([ + 'publish', // Publish packages to registry + 'read', // Read listing/analytics data + 'manage', // Manage listings (update, deprecate) + 'admin', // Full access (manage team, keys) +]); + +/** + * Developer API Key + */ +export const DeveloperApiKeySchema = z.object({ + /** Key identifier (not the secret) */ + id: z.string().describe('API key identifier'), + + /** Human-readable label */ + label: z.string().describe('Key label (e.g., "CI/CD Pipeline")'), + + /** Scopes granted to this key */ + scopes: z.array(ApiKeyScopeSchema).min(1).describe('Permissions granted'), + + /** Key prefix (first 8 chars) for identification */ + prefix: z.string().max(8).optional().describe('Key prefix for display'), + + /** Expiration date (optional) */ + expiresAt: z.string().datetime().optional(), + + /** Creation timestamp */ + createdAt: z.string().datetime(), + + /** Last used timestamp */ + lastUsedAt: z.string().datetime().optional(), + + /** Whether this key is currently active */ + active: z.boolean().default(true), +}); + +/** + * Developer Account Schema + * + * Represents a registered developer or organization in the portal. + */ +export const DeveloperAccountSchema = z.object({ + /** Account unique identifier */ + id: z.string().describe('Developer account ID'), + + /** Publisher ID (links to PublisherSchema in marketplace) */ + publisherId: z.string().describe('Associated publisher ID'), + + /** Account status */ + status: DeveloperAccountStatusSchema.default('pending'), + + /** Verification level (from marketplace publisher) */ + verification: PublisherVerificationSchema.default('unverified'), + + /** Organization name */ + organizationName: z.string().describe('Organization or developer name'), + + /** Primary contact email */ + email: z.string().email().describe('Primary contact email'), + + /** Team members (user IDs with roles) */ + teamMembers: z.array(z.object({ + userId: z.string(), + role: z.enum(['owner', 'admin', 'developer', 'viewer']), + joinedAt: z.string().datetime().optional(), + })).optional().describe('Team member list'), + + /** Accepted developer agreement version */ + agreementVersion: z.string().optional().describe('Accepted ToS version'), + + /** Registration timestamp */ + registeredAt: z.string().datetime(), +}); + +// ========================================== +// Version Channels & Release Management +// ========================================== + +/** + * Release Channel — allows pre-release distribution + */ +export const ReleaseChannelSchema = z.enum([ + 'alpha', // Early development, unstable + 'beta', // Feature-complete, testing phase + 'rc', // Release candidate, final testing + 'stable', // Production-ready, general availability +]); + +/** + * Version Release Schema + * + * A single version release of a package with channel assignment. + */ +export const VersionReleaseSchema = z.object({ + /** Semver version string */ + version: z.string().describe('Semver version (e.g., 2.1.0-beta.1)'), + + /** Release channel */ + channel: ReleaseChannelSchema.default('stable'), + + /** Release notes (Markdown) */ + releaseNotes: z.string().optional().describe('Release notes (Markdown)'), + + /** Changelog entries (structured) */ + changelog: z.array(z.object({ + type: z.enum(['added', 'changed', 'fixed', 'removed', 'deprecated', 'security']), + description: z.string(), + })).optional().describe('Structured changelog entries'), + + /** Minimum platform version required */ + minPlatformVersion: z.string().optional(), + + /** Build artifact URL */ + artifactUrl: z.string().optional().describe('Built package artifact URL'), + + /** Artifact checksum (integrity) */ + artifactChecksum: z.string().optional().describe('SHA-256 checksum'), + + /** Whether this version is deprecated */ + deprecated: z.boolean().default(false), + + /** Deprecation message (if deprecated) */ + deprecationMessage: z.string().optional(), + + /** Release timestamp */ + releasedAt: z.string().datetime().optional(), +}); + +// ========================================== +// App Listing Management (Developer CRUD) +// ========================================== + +/** + * Create Listing Request — developer creates a new marketplace listing + */ +export const CreateListingRequestSchema = z.object({ + /** Package ID (reverse domain, e.g., com.acme.crm) */ + packageId: z.string().describe('Package identifier'), + + /** Display name */ + name: z.string().describe('App display name'), + + /** Short tagline (max 120 chars) */ + tagline: z.string().max(120).optional(), + + /** Full description (Markdown) */ + description: z.string().optional(), + + /** Category */ + category: z.string().describe('Marketplace category'), + + /** Additional tags */ + tags: z.array(z.string()).optional(), + + /** Icon URL */ + iconUrl: z.string().url().optional(), + + /** Screenshots */ + screenshots: z.array(z.object({ + url: z.string().url(), + caption: z.string().optional(), + })).optional(), + + /** Documentation URL */ + documentationUrl: z.string().url().optional(), + + /** Support URL */ + supportUrl: z.string().url().optional(), + + /** Source repository URL */ + repositoryUrl: z.string().url().optional(), + + /** Pricing model */ + pricing: z.enum([ + 'free', 'freemium', 'paid', 'subscription', 'usage-based', 'contact-sales', + ]).default('free'), + + /** Price in cents (if paid) */ + priceInCents: z.number().int().min(0).optional(), +}); + +/** + * Update Listing Request — developer updates listing metadata + */ +export const UpdateListingRequestSchema = z.object({ + /** Listing ID */ + listingId: z.string().describe('Listing ID to update'), + + /** Updatable fields (all optional, partial update) */ + name: z.string().optional(), + tagline: z.string().max(120).optional(), + description: z.string().optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + iconUrl: z.string().url().optional(), + screenshots: z.array(z.object({ + url: z.string().url(), + caption: z.string().optional(), + })).optional(), + documentationUrl: z.string().url().optional(), + supportUrl: z.string().url().optional(), + repositoryUrl: z.string().url().optional(), + pricing: z.enum([ + 'free', 'freemium', 'paid', 'subscription', 'usage-based', 'contact-sales', + ]).optional(), + priceInCents: z.number().int().min(0).optional(), +}); + +/** + * Listing Action Request — lifecycle actions on a listing + */ +export const ListingActionRequestSchema = z.object({ + /** Listing ID */ + listingId: z.string().describe('Listing ID'), + + /** Action to perform */ + action: z.enum([ + 'submit', // Submit for review + 'unlist', // Remove from public search (keep accessible by direct link) + 'deprecate', // Mark as deprecated + 'reactivate', // Reactivate unlisted/deprecated listing + ]).describe('Action to perform on listing'), + + /** Reason for action (e.g., deprecation message) */ + reason: z.string().optional(), +}); + +// ========================================== +// Publishing Analytics (Developer Dashboard) +// ========================================== + +/** + * Analytics Time Range + */ +export const AnalyticsTimeRangeSchema = z.enum([ + 'last_7d', + 'last_30d', + 'last_90d', + 'last_365d', + 'all_time', +]); + +/** + * Publishing Analytics Request + */ +export const PublishingAnalyticsRequestSchema = z.object({ + /** Listing ID */ + listingId: z.string().describe('Listing to get analytics for'), + + /** Time range */ + timeRange: AnalyticsTimeRangeSchema.default('last_30d'), + + /** Metrics to include */ + metrics: z.array(z.enum([ + 'installs', // Install count over time + 'uninstalls', // Uninstall count over time + 'active_installs', // Active install trend + 'ratings', // Rating distribution + 'revenue', // Revenue (for paid apps) + 'page_views', // Listing page views + ])).optional().describe('Metrics to include (default: all)'), +}); + +/** + * Time Series Data Point + */ +export const TimeSeriesPointSchema = z.object({ + /** ISO date string (day granularity) */ + date: z.string(), + /** Metric value */ + value: z.number(), +}); + +/** + * Publishing Analytics Response + */ +export const PublishingAnalyticsResponseSchema = z.object({ + /** Listing ID */ + listingId: z.string(), + + /** Time range */ + timeRange: AnalyticsTimeRangeSchema, + + /** Summary statistics */ + summary: z.object({ + totalInstalls: z.number().int().min(0), + activeInstalls: z.number().int().min(0), + totalUninstalls: z.number().int().min(0), + averageRating: z.number().min(0).max(5).optional(), + totalRatings: z.number().int().min(0), + totalRevenue: z.number().min(0).optional().describe('Revenue in cents'), + pageViews: z.number().int().min(0), + }), + + /** Time series data by metric */ + timeSeries: z.record(z.string(), z.array(TimeSeriesPointSchema)).optional() + .describe('Time series keyed by metric name'), + + /** Rating distribution (1-5 stars) */ + ratingDistribution: z.object({ + 1: z.number().int().min(0).default(0), + 2: z.number().int().min(0).default(0), + 3: z.number().int().min(0).default(0), + 4: z.number().int().min(0).default(0), + 5: z.number().int().min(0).default(0), + }).optional(), +}); + +// ========================================== +// Export Types +// ========================================== + +export type DeveloperAccountStatus = z.infer; +export type ApiKeyScope = z.infer; +export type DeveloperApiKey = z.infer; +export type DeveloperAccount = z.infer; +export type ReleaseChannel = z.infer; +export type VersionRelease = z.infer; +export type CreateListingRequest = z.infer; +export type UpdateListingRequest = z.infer; +export type ListingActionRequest = z.infer; +export type AnalyticsTimeRange = z.infer; +export type PublishingAnalyticsRequest = z.infer; +export type TimeSeriesPoint = z.infer; +export type PublishingAnalyticsResponse = z.infer; diff --git a/packages/spec/src/cloud/index.ts b/packages/spec/src/cloud/index.ts index 283756b52..4fa2e5249 100644 --- a/packages/spec/src/cloud/index.ts +++ b/packages/spec/src/cloud/index.ts @@ -2,10 +2,16 @@ /** * Cloud Protocol - * + * * Cloud-specific protocols for the ObjectStack SaaS platform. * These schemas define the contract for cloud services like: * - Marketplace (listing, publishing, review, search, install) + * - Developer Portal (developer registration, API keys, publishing analytics) + * - Marketplace Administration (review workflow, curation, governance) + * - App Store (customer experience: reviews, recommendations, subscriptions) * - Future: Composer, Space, Hub Federation */ export * from './marketplace.zod'; +export * from './developer-portal.zod'; +export * from './marketplace-admin.zod'; +export * from './app-store.zod'; diff --git a/packages/spec/src/cloud/marketplace-admin.test.ts b/packages/spec/src/cloud/marketplace-admin.test.ts new file mode 100644 index 000000000..7510dd7f4 --- /dev/null +++ b/packages/spec/src/cloud/marketplace-admin.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect } from 'vitest'; +import { + ReviewCriterionSchema, + ReviewDecisionSchema, + RejectionReasonSchema, + SubmissionReviewSchema, + FeaturedListingSchema, + CuratedCollectionSchema, + PolicyViolationTypeSchema, + PolicyActionSchema, + MarketplaceHealthMetricsSchema, + TrendingListingSchema, +} from './marketplace-admin.zod'; + +describe('ReviewCriterionSchema', () => { + it('should accept a required criterion', () => { + const criterion = { + id: 'sec-001', + category: 'security' as const, + description: 'No known vulnerabilities in dependencies', + }; + const parsed = ReviewCriterionSchema.parse(criterion); + expect(parsed.required).toBe(true); + }); + + it('should accept a completed criterion', () => { + const criterion = { + id: 'ux-001', + category: 'ux' as const, + description: 'Follows ObjectStack UI guidelines', + required: false, + passed: true, + notes: 'Clean UI, follows design system', + }; + const parsed = ReviewCriterionSchema.parse(criterion); + expect(parsed.passed).toBe(true); + }); + + it('should accept all criterion categories', () => { + const categories = [ + 'security', 'performance', 'quality', 'ux', + 'documentation', 'policy', 'compatibility', + ]; + categories.forEach(category => { + const criterion = { + id: `test-${category}`, + category, + description: `Test ${category}`, + }; + expect(() => ReviewCriterionSchema.parse(criterion)).not.toThrow(); + }); + }); +}); + +describe('ReviewDecisionSchema', () => { + it('should accept all decisions', () => { + const decisions = ['approved', 'rejected', 'changes-requested']; + decisions.forEach(decision => { + expect(() => ReviewDecisionSchema.parse(decision)).not.toThrow(); + }); + }); +}); + +describe('RejectionReasonSchema', () => { + it('should accept all rejection reasons', () => { + const reasons = [ + 'security-vulnerability', 'policy-violation', 'quality-below-standard', + 'misleading-metadata', 'incompatible', 'duplicate', + 'insufficient-documentation', 'other', + ]; + reasons.forEach(reason => { + expect(() => RejectionReasonSchema.parse(reason)).not.toThrow(); + }); + }); +}); + +describe('SubmissionReviewSchema', () => { + it('should accept minimal review (in-progress)', () => { + const review = { + id: 'review-001', + submissionId: 'sub-001', + reviewerId: 'admin-001', + startedAt: '2025-06-01T10:00:00Z', + }; + const parsed = SubmissionReviewSchema.parse(review); + expect(parsed.decision).toBeUndefined(); + }); + + it('should accept approved review with criteria', () => { + const review = { + id: 'review-001', + submissionId: 'sub-001', + reviewerId: 'admin-001', + decision: 'approved' as const, + criteria: [ + { id: 'sec-001', category: 'security' as const, description: 'No vulnerabilities', passed: true }, + { id: 'qual-001', category: 'quality' as const, description: 'Code quality', passed: true }, + ], + feedback: 'Looks great! Approved for publishing.', + startedAt: '2025-06-01T10:00:00Z', + completedAt: '2025-06-01T14:00:00Z', + }; + const parsed = SubmissionReviewSchema.parse(review); + expect(parsed.criteria).toHaveLength(2); + }); + + it('should accept rejected review with reasons', () => { + const review = { + id: 'review-002', + submissionId: 'sub-002', + reviewerId: 'admin-001', + decision: 'rejected' as const, + rejectionReasons: ['security-vulnerability', 'insufficient-documentation'], + feedback: '## Issues Found\n\n1. Critical CVE in lodash dependency\n2. Missing API documentation', + internalNotes: 'Publisher notified via email', + startedAt: '2025-06-01T10:00:00Z', + completedAt: '2025-06-01T11:30:00Z', + }; + const parsed = SubmissionReviewSchema.parse(review); + expect(parsed.rejectionReasons).toHaveLength(2); + expect(parsed.decision).toBe('rejected'); + }); +}); + +describe('FeaturedListingSchema', () => { + it('should accept minimal featured listing', () => { + const featured = { + listingId: 'listing-001', + startDate: '2025-06-01T00:00:00Z', + }; + const parsed = FeaturedListingSchema.parse(featured); + expect(parsed.active).toBe(true); + expect(parsed.priority).toBe(0); + }); + + it('should accept full featured listing', () => { + const featured = { + listingId: 'listing-001', + priority: 1, + bannerUrl: 'https://marketplace.objectstack.io/banners/crm.png', + editorialNote: 'Best CRM of the month', + startDate: '2025-06-01T00:00:00Z', + endDate: '2025-06-30T23:59:59Z', + active: true, + }; + const parsed = FeaturedListingSchema.parse(featured); + expect(parsed.editorialNote).toBe('Best CRM of the month'); + }); +}); + +describe('CuratedCollectionSchema', () => { + it('should accept collection with listings', () => { + const collection = { + id: 'col-001', + name: 'Best for Small Business', + description: 'Curated apps perfect for small businesses', + coverImageUrl: 'https://marketplace.objectstack.io/covers/smb.png', + listingIds: ['listing-001', 'listing-002', 'listing-003'], + published: true, + sortOrder: 1, + createdBy: 'admin-001', + createdAt: '2025-06-01T00:00:00Z', + }; + const parsed = CuratedCollectionSchema.parse(collection); + expect(parsed.listingIds).toHaveLength(3); + expect(parsed.published).toBe(true); + }); + + it('should require at least one listing', () => { + const collection = { + id: 'col-001', + name: 'Empty Collection', + listingIds: [], + }; + expect(() => CuratedCollectionSchema.parse(collection)).toThrow(); + }); +}); + +describe('PolicyViolationTypeSchema', () => { + it('should accept all violation types', () => { + const types = [ + 'malware', 'data-harvesting', 'spam', 'copyright', + 'inappropriate-content', 'terms-of-service', 'security-risk', 'abandoned', + ]; + types.forEach(type => { + expect(() => PolicyViolationTypeSchema.parse(type)).not.toThrow(); + }); + }); +}); + +describe('PolicyActionSchema', () => { + it('should accept a warning action', () => { + const action = { + id: 'action-001', + listingId: 'listing-005', + violationType: 'spam' as const, + action: 'warning' as const, + reason: 'Listing description contains misleading claims about features', + actionBy: 'admin-001', + actionAt: '2025-06-15T10:00:00Z', + }; + const parsed = PolicyActionSchema.parse(action); + expect(parsed.resolved).toBe(false); + }); + + it('should accept a takedown action with resolution', () => { + const action = { + id: 'action-002', + listingId: 'listing-010', + violationType: 'malware' as const, + action: 'takedown' as const, + reason: 'Malicious code detected in v1.2.0', + actionBy: 'admin-002', + actionAt: '2025-06-15T10:00:00Z', + resolution: 'Publisher removed malicious code, re-submitted clean version', + resolved: true, + }; + const parsed = PolicyActionSchema.parse(action); + expect(parsed.resolved).toBe(true); + }); + + it('should accept all enforcement actions', () => { + const actions = ['warning', 'suspend', 'takedown', 'restrict']; + actions.forEach(a => { + const action = { + id: 'action-001', + listingId: 'listing-001', + violationType: 'terms-of-service' as const, + action: a, + reason: 'Test', + actionBy: 'admin-001', + actionAt: '2025-06-15T10:00:00Z', + }; + expect(() => PolicyActionSchema.parse(action)).not.toThrow(); + }); + }); +}); + +describe('MarketplaceHealthMetricsSchema', () => { + it('should accept health metrics', () => { + const metrics = { + totalListings: 250, + totalPublishers: 80, + verifiedPublishers: 45, + totalInstalls: 150000, + averageReviewTime: 48.5, + pendingReviews: 12, + snapshotAt: '2025-06-15T00:00:00Z', + }; + const parsed = MarketplaceHealthMetricsSchema.parse(metrics); + expect(parsed.totalListings).toBe(250); + expect(parsed.pendingReviews).toBe(12); + }); + + it('should accept metrics with breakdowns', () => { + const metrics = { + totalListings: 250, + listingsByStatus: { + published: 200, + draft: 30, + 'in-review': 12, + suspended: 8, + }, + listingsByCategory: { + crm: 40, + erp: 25, + analytics: 35, + }, + totalPublishers: 80, + verifiedPublishers: 45, + totalInstalls: 150000, + pendingReviews: 12, + listingsByPricing: { + free: 120, + freemium: 50, + subscription: 60, + paid: 20, + }, + snapshotAt: '2025-06-15T00:00:00Z', + }; + const parsed = MarketplaceHealthMetricsSchema.parse(metrics); + expect(parsed.listingsByCategory?.crm).toBe(40); + }); +}); + +describe('TrendingListingSchema', () => { + it('should accept trending listing', () => { + const trending = { + listingId: 'listing-001', + rank: 1, + trendScore: 95.5, + installVelocity: 120.3, + period: '7d', + }; + const parsed = TrendingListingSchema.parse(trending); + expect(parsed.rank).toBe(1); + expect(parsed.trendScore).toBe(95.5); + }); +}); diff --git a/packages/spec/src/cloud/marketplace-admin.zod.ts b/packages/spec/src/cloud/marketplace-admin.zod.ts new file mode 100644 index 000000000..3b59cee62 --- /dev/null +++ b/packages/spec/src/cloud/marketplace-admin.zod.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * # Marketplace Administration Protocol + * + * Defines schemas for the platform (Cloud) side of marketplace operations. + * Covers the administrative workflows for managing and governing the marketplace. + * + * ## Architecture Alignment + * - **Salesforce AppExchange Admin**: Security review, ISV monitoring, partner management + * - **Apple App Store Connect Review**: Human review process, guidelines, rejection reasons + * - **Google Play Console**: Policy enforcement, quality gates, content moderation + * + * ## Key Concepts + * - **Review Process**: Structured workflow for submission review (automated + manual) + * - **Curation**: Featured apps, curated collections, editorial picks + * - **Governance**: Policy enforcement, takedown, compliance + * - **Platform Analytics**: Marketplace health, trending, abuse detection + */ + +// ========================================== +// Review Process +// ========================================== + +/** + * Review Criteria — checklist items for human reviewers + */ +export const ReviewCriterionSchema = z.object({ + /** Criterion identifier */ + id: z.string().describe('Criterion ID'), + + /** Category of criterion */ + category: z.enum([ + 'security', // Security best practices + 'performance', // Performance / resource usage + 'quality', // Code quality / best practices + 'ux', // User experience standards + 'documentation', // Documentation completeness + 'policy', // Policy compliance (no malware, GDPR, etc.) + 'compatibility', // Platform compatibility + ]), + + /** Description of what to check */ + description: z.string(), + + /** Whether this criterion must pass for approval */ + required: z.boolean().default(true), + + /** Pass/fail result */ + passed: z.boolean().optional(), + + /** Reviewer notes for this criterion */ + notes: z.string().optional(), +}); + +/** + * Review Decision + */ +export const ReviewDecisionSchema = z.enum([ + 'approved', // Approved for publishing + 'rejected', // Rejected (with reasons) + 'changes-requested', // Needs changes before re-review +]); + +/** + * Rejection Reason Category + */ +export const RejectionReasonSchema = z.enum([ + 'security-vulnerability', // Security issues found + 'policy-violation', // Violates marketplace policy + 'quality-below-standard', // Does not meet quality bar + 'misleading-metadata', // Listing info doesn't match functionality + 'incompatible', // Incompatible with current platform + 'duplicate', // Duplicate of existing listing + 'insufficient-documentation', // Inadequate documentation + 'other', // Other reason (see notes) +]); + +/** + * Submission Review Schema + * + * The review record attached to a package submission. + */ +export const SubmissionReviewSchema = z.object({ + /** Review ID */ + id: z.string().describe('Review ID'), + + /** Submission ID being reviewed */ + submissionId: z.string().describe('Submission being reviewed'), + + /** Reviewer user ID */ + reviewerId: z.string().describe('Platform reviewer ID'), + + /** Review decision */ + decision: ReviewDecisionSchema.optional().describe('Final decision'), + + /** Review criteria checklist */ + criteria: z.array(ReviewCriterionSchema).optional() + .describe('Review checklist results'), + + /** Rejection reasons (if rejected) */ + rejectionReasons: z.array(RejectionReasonSchema).optional(), + + /** Detailed feedback for the developer */ + feedback: z.string().optional().describe('Detailed review feedback (Markdown)'), + + /** Internal notes (not visible to developer) */ + internalNotes: z.string().optional().describe('Internal reviewer notes'), + + /** Review started timestamp */ + startedAt: z.string().datetime().optional(), + + /** Review completed timestamp */ + completedAt: z.string().datetime().optional(), +}); + +// ========================================== +// Curation: Featured & Collections +// ========================================== + +/** + * Featured Listing — promoted on marketplace homepage + */ +export const FeaturedListingSchema = z.object({ + /** Listing ID */ + listingId: z.string().describe('Featured listing ID'), + + /** Featured position/priority (lower = higher priority) */ + priority: z.number().int().min(0).default(0), + + /** Featured banner image URL */ + bannerUrl: z.string().url().optional(), + + /** Featured reason / editorial note */ + editorialNote: z.string().optional(), + + /** Start date for featured period */ + startDate: z.string().datetime(), + + /** End date for featured period */ + endDate: z.string().datetime().optional(), + + /** Whether currently active */ + active: z.boolean().default(true), +}); + +/** + * Curated Collection — a themed group of listings + */ +export const CuratedCollectionSchema = z.object({ + /** Collection unique identifier */ + id: z.string().describe('Collection ID'), + + /** Collection display name */ + name: z.string().describe('Collection name'), + + /** Collection description */ + description: z.string().optional(), + + /** Cover image URL */ + coverImageUrl: z.string().url().optional(), + + /** Listing IDs in this collection (ordered) */ + listingIds: z.array(z.string()).min(1).describe('Ordered listing IDs'), + + /** Whether publicly visible */ + published: z.boolean().default(false), + + /** Sort order for display among collections */ + sortOrder: z.number().int().min(0).default(0), + + /** Created by (admin user ID) */ + createdBy: z.string().optional(), + + /** Created at */ + createdAt: z.string().datetime().optional(), + + /** Updated at */ + updatedAt: z.string().datetime().optional(), +}); + +// ========================================== +// Governance & Policy +// ========================================== + +/** + * Policy Violation Type + */ +export const PolicyViolationTypeSchema = z.enum([ + 'malware', // Malicious software + 'data-harvesting', // Unauthorized data collection + 'spam', // Spammy or misleading content + 'copyright', // Copyright/IP infringement + 'inappropriate-content', // Inappropriate or offensive content + 'terms-of-service', // General ToS violation + 'security-risk', // Unresolved critical security issues + 'abandoned', // Abandoned / no longer maintained +]); + +/** + * Policy Action — enforcement action on a listing + */ +export const PolicyActionSchema = z.object({ + /** Action ID */ + id: z.string().describe('Action ID'), + + /** Listing ID */ + listingId: z.string().describe('Target listing ID'), + + /** Violation type */ + violationType: PolicyViolationTypeSchema, + + /** Action taken */ + action: z.enum([ + 'warning', // Warning to publisher + 'suspend', // Temporarily suspend listing + 'takedown', // Permanently remove listing + 'restrict', // Restrict new installs (existing users keep access) + ]), + + /** Detailed reason */ + reason: z.string().describe('Explanation of the violation'), + + /** Admin user who took the action */ + actionBy: z.string().describe('Admin user ID'), + + /** Timestamp */ + actionAt: z.string().datetime(), + + /** Resolution notes (if resolved) */ + resolution: z.string().optional(), + + /** Whether resolved */ + resolved: z.boolean().default(false), +}); + +// ========================================== +// Platform Analytics +// ========================================== + +/** + * Marketplace Health Metrics — overall platform statistics + */ +export const MarketplaceHealthMetricsSchema = z.object({ + /** Total number of published listings */ + totalListings: z.number().int().min(0), + + /** Listings by status breakdown (partial — only non-zero statuses) */ + listingsByStatus: z.record(z.string(), z.number().int().min(0)).optional(), + + /** Listings by category breakdown (partial — only non-zero categories) */ + listingsByCategory: z.record(z.string(), z.number().int().min(0)).optional(), + + /** Total registered publishers */ + totalPublishers: z.number().int().min(0), + + /** Verified publishers count */ + verifiedPublishers: z.number().int().min(0), + + /** Total installs across all listings (all time) */ + totalInstalls: z.number().int().min(0), + + /** Average time from submission to review completion (hours) */ + averageReviewTime: z.number().min(0).optional(), + + /** Pending review queue size */ + pendingReviews: z.number().int().min(0), + + /** Listings by pricing model (partial — only non-zero models) */ + listingsByPricing: z.record(z.string(), z.number().int().min(0)).optional(), + + /** Snapshot timestamp */ + snapshotAt: z.string().datetime(), +}); + +/** + * Trending Listing — computed from recent activity + */ +export const TrendingListingSchema = z.object({ + /** Listing ID */ + listingId: z.string(), + + /** Trending rank (1 = most trending) */ + rank: z.number().int().min(1), + + /** Trend score (computed from velocity of installs, ratings, page views) */ + trendScore: z.number().min(0), + + /** Install velocity (installs per day over measurement period) */ + installVelocity: z.number().min(0), + + /** Measurement period (e.g., "7d", "30d") */ + period: z.string(), +}); + +// ========================================== +// Export Types +// ========================================== + +export type ReviewCriterion = z.infer; +export type ReviewDecision = z.infer; +export type RejectionReason = z.infer; +export type SubmissionReview = z.infer; +export type FeaturedListing = z.infer; +export type CuratedCollection = z.infer; +export type PolicyViolationType = z.infer; +export type PolicyAction = z.infer; +export type MarketplaceHealthMetrics = z.infer; +export type TrendingListing = z.infer; From 0189de6af7b68cd85e3d2e36c1fa33f4cb851fbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:54:50 +0000 Subject: [PATCH 3/3] refactor(cloud): reuse Identity module for auth/org/API keys (better-auth aligned) - Remove DeveloperAccountSchema, DeveloperApiKeySchema, ApiKeyScopeSchema, DeveloperAccountStatusSchema from developer-portal.zod.ts - Add ApiKeySchema to Identity module following better-auth's API key plugin - Add PublisherProfileSchema that links Identity.Organization to marketplace - Update developer-portal.zod.ts docs to reference Identity namespace Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../spec/src/cloud/developer-portal.test.ts | 121 +++++------------- .../spec/src/cloud/developer-portal.zod.ts | 112 +++++----------- packages/spec/src/identity/identity.test.ts | 74 +++++++++++ packages/spec/src/identity/identity.zod.ts | 109 ++++++++++++++++ 4 files changed, 244 insertions(+), 172 deletions(-) diff --git a/packages/spec/src/cloud/developer-portal.test.ts b/packages/spec/src/cloud/developer-portal.test.ts index fcb7bb71a..3358e229e 100644 --- a/packages/spec/src/cloud/developer-portal.test.ts +++ b/packages/spec/src/cloud/developer-portal.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - DeveloperAccountStatusSchema, - ApiKeyScopeSchema, - DeveloperApiKeySchema, - DeveloperAccountSchema, + PublisherProfileSchema, ReleaseChannelSchema, VersionReleaseSchema, CreateListingRequestSchema, @@ -15,110 +12,50 @@ import { TimeSeriesPointSchema, } from './developer-portal.zod'; -describe('DeveloperAccountStatusSchema', () => { - it('should accept all valid statuses', () => { - const statuses = ['pending', 'active', 'suspended', 'deactivated']; - statuses.forEach(status => { - expect(() => DeveloperAccountStatusSchema.parse(status)).not.toThrow(); - }); - }); - - it('should reject invalid status', () => { - expect(() => DeveloperAccountStatusSchema.parse('banned')).toThrow(); - }); -}); - -describe('ApiKeyScopeSchema', () => { - it('should accept all valid scopes', () => { - const scopes = ['publish', 'read', 'manage', 'admin']; - scopes.forEach(scope => { - expect(() => ApiKeyScopeSchema.parse(scope)).not.toThrow(); - }); - }); -}); - -describe('DeveloperApiKeySchema', () => { - it('should accept minimal API key', () => { - const key = { - id: 'key-001', - label: 'CI/CD Pipeline', - scopes: ['publish'], - createdAt: '2025-06-01T00:00:00Z', - }; - const parsed = DeveloperApiKeySchema.parse(key); - expect(parsed.active).toBe(true); - }); - - it('should accept full API key', () => { - const key = { - id: 'key-001', - label: 'CI/CD Pipeline', - scopes: ['publish', 'read'], - prefix: 'os_pk_ab', - expiresAt: '2026-06-01T00:00:00Z', - createdAt: '2025-06-01T00:00:00Z', - lastUsedAt: '2025-09-15T10:30:00Z', - active: true, - }; - const parsed = DeveloperApiKeySchema.parse(key); - expect(parsed.scopes).toHaveLength(2); - expect(parsed.prefix).toBe('os_pk_ab'); - }); - - it('should require at least one scope', () => { - const key = { - id: 'key-001', - label: 'Empty', - scopes: [], - createdAt: '2025-06-01T00:00:00Z', - }; - expect(() => DeveloperApiKeySchema.parse(key)).toThrow(); - }); -}); - -describe('DeveloperAccountSchema', () => { - it('should accept minimal account', () => { - const account = { - id: 'dev-001', +describe('PublisherProfileSchema', () => { + it('should accept minimal publisher profile', () => { + const profile = { + organizationId: 'org-001', publisherId: 'pub-001', - organizationName: 'Acme Corp', - email: 'dev@acme.com', registeredAt: '2025-01-15T10:00:00Z', }; - const parsed = DeveloperAccountSchema.parse(account); - expect(parsed.status).toBe('pending'); + const parsed = PublisherProfileSchema.parse(profile); expect(parsed.verification).toBe('unverified'); }); - it('should accept full account with team members', () => { - const account = { - id: 'dev-001', + it('should accept full publisher profile', () => { + const profile = { + organizationId: 'org-001', publisherId: 'pub-001', - status: 'active' as const, verification: 'verified' as const, - organizationName: 'Acme Corp', - email: 'dev@acme.com', - teamMembers: [ - { userId: 'user-001', role: 'owner' as const, joinedAt: '2025-01-15T10:00:00Z' }, - { userId: 'user-002', role: 'developer' as const }, - ], agreementVersion: '2.0', + website: 'https://acme.com', + supportEmail: 'support@acme.com', + registeredAt: '2025-01-15T10:00:00Z', + }; + const parsed = PublisherProfileSchema.parse(profile); + expect(parsed.verification).toBe('verified'); + expect(parsed.agreementVersion).toBe('2.0'); + }); + + it('should require valid support email', () => { + const profile = { + organizationId: 'org-001', + publisherId: 'pub-001', + supportEmail: 'not-an-email', registeredAt: '2025-01-15T10:00:00Z', }; - const parsed = DeveloperAccountSchema.parse(account); - expect(parsed.teamMembers).toHaveLength(2); - expect(parsed.status).toBe('active'); + expect(() => PublisherProfileSchema.parse(profile)).toThrow(); }); - it('should require valid email', () => { - const account = { - id: 'dev-001', + it('should require valid website URL', () => { + const profile = { + organizationId: 'org-001', publisherId: 'pub-001', - organizationName: 'Acme Corp', - email: 'not-an-email', + website: 'not-a-url', registeredAt: '2025-01-15T10:00:00Z', }; - expect(() => DeveloperAccountSchema.parse(account)).toThrow(); + expect(() => PublisherProfileSchema.parse(profile)).toThrow(); }); }); diff --git a/packages/spec/src/cloud/developer-portal.zod.ts b/packages/spec/src/cloud/developer-portal.zod.ts index 0ac4610be..042154a07 100644 --- a/packages/spec/src/cloud/developer-portal.zod.ts +++ b/packages/spec/src/cloud/developer-portal.zod.ts @@ -18,101 +18,56 @@ import { PublisherVerificationSchema } from './marketplace.zod'; * - **Shopify Partner Dashboard**: App management, analytics, billing * - **VS Code Marketplace Management**: Extension publishing, statistics, tokens * + * ## Identity Integration (better-auth) + * Authentication, organization management, and API keys are handled by the + * Identity module (`@objectstack/spec` Identity namespace), which follows the + * better-auth specification. This module only defines marketplace-specific + * extensions on top of the shared identity layer: + * + * - **User & Session** → `Identity.UserSchema`, `Identity.SessionSchema` + * - **Organization & Members** → `Identity.OrganizationSchema`, `Identity.MemberSchema` + * - **API Keys** → `Identity.ApiKeySchema` (with marketplace scopes) + * * ## Key Concepts - * - **Developer Account**: Registration and API key management + * - **Publisher Profile**: Links an Identity Organization to a marketplace publisher * - **App Listing Management**: CRUD for marketplace listings (draft → published) * - **Version Channels**: alpha / beta / rc / stable release channels * - **Publishing Analytics**: Install trends, revenue, ratings over time */ // ========================================== -// Developer Account & API Keys +// Publisher Profile (extends Identity.Organization) // ========================================== /** - * Developer Account Status - */ -export const DeveloperAccountStatusSchema = z.enum([ - 'pending', // Registration submitted, awaiting approval - 'active', // Account active and can publish - 'suspended', // Temporarily suspended (policy violation) - 'deactivated', // Deactivated by developer -]); - -/** - * API Key Scope — controls what the key can do - */ -export const ApiKeyScopeSchema = z.enum([ - 'publish', // Publish packages to registry - 'read', // Read listing/analytics data - 'manage', // Manage listings (update, deprecate) - 'admin', // Full access (manage team, keys) -]); - -/** - * Developer API Key - */ -export const DeveloperApiKeySchema = z.object({ - /** Key identifier (not the secret) */ - id: z.string().describe('API key identifier'), - - /** Human-readable label */ - label: z.string().describe('Key label (e.g., "CI/CD Pipeline")'), - - /** Scopes granted to this key */ - scopes: z.array(ApiKeyScopeSchema).min(1).describe('Permissions granted'), - - /** Key prefix (first 8 chars) for identification */ - prefix: z.string().max(8).optional().describe('Key prefix for display'), - - /** Expiration date (optional) */ - expiresAt: z.string().datetime().optional(), - - /** Creation timestamp */ - createdAt: z.string().datetime(), - - /** Last used timestamp */ - lastUsedAt: z.string().datetime().optional(), - - /** Whether this key is currently active */ - active: z.boolean().default(true), -}); - -/** - * Developer Account Schema + * Publisher Profile Schema * - * Represents a registered developer or organization in the portal. + * Links an Identity Organization to a marketplace publisher identity. + * The organization itself (name, slug, logo, members) is managed via + * Identity.OrganizationSchema and Identity.MemberSchema (better-auth aligned). + * + * This schema only holds marketplace-specific publisher metadata. */ -export const DeveloperAccountSchema = z.object({ - /** Account unique identifier */ - id: z.string().describe('Developer account ID'), - - /** Publisher ID (links to PublisherSchema in marketplace) */ - publisherId: z.string().describe('Associated publisher ID'), +export const PublisherProfileSchema = z.object({ + /** Organization ID (references Identity.Organization.id) */ + organizationId: z.string().describe('Identity Organization ID'), - /** Account status */ - status: DeveloperAccountStatusSchema.default('pending'), + /** Publisher ID (marketplace-assigned identifier) */ + publisherId: z.string().describe('Marketplace publisher ID'), - /** Verification level (from marketplace publisher) */ + /** Verification level (marketplace trust tier) */ verification: PublisherVerificationSchema.default('unverified'), - /** Organization name */ - organizationName: z.string().describe('Organization or developer name'), - - /** Primary contact email */ - email: z.string().email().describe('Primary contact email'), + /** Accepted developer program agreement version */ + agreementVersion: z.string().optional().describe('Accepted developer agreement version'), - /** Team members (user IDs with roles) */ - teamMembers: z.array(z.object({ - userId: z.string(), - role: z.enum(['owner', 'admin', 'developer', 'viewer']), - joinedAt: z.string().datetime().optional(), - })).optional().describe('Team member list'), + /** Publisher-specific website (may differ from org) */ + website: z.string().url().optional().describe('Publisher website'), - /** Accepted developer agreement version */ - agreementVersion: z.string().optional().describe('Accepted ToS version'), + /** Publisher-specific support email */ + supportEmail: z.string().email().optional().describe('Publisher support email'), - /** Registration timestamp */ + /** Registration timestamp (when org became a publisher) */ registeredAt: z.string().datetime(), }); @@ -354,10 +309,7 @@ export const PublishingAnalyticsResponseSchema = z.object({ // Export Types // ========================================== -export type DeveloperAccountStatus = z.infer; -export type ApiKeyScope = z.infer; -export type DeveloperApiKey = z.infer; -export type DeveloperAccount = z.infer; +export type PublisherProfile = z.infer; export type ReleaseChannel = z.infer; export type VersionRelease = z.infer; export type CreateListingRequest = z.infer; diff --git a/packages/spec/src/identity/identity.test.ts b/packages/spec/src/identity/identity.test.ts index 405acc07f..8873f7d44 100644 --- a/packages/spec/src/identity/identity.test.ts +++ b/packages/spec/src/identity/identity.test.ts @@ -4,10 +4,12 @@ import { AccountSchema, SessionSchema, VerificationTokenSchema, + ApiKeySchema, type User, type Account, type Session, type VerificationToken, + type ApiKey, } from "./identity.zod"; describe('UserSchema', () => { @@ -247,6 +249,78 @@ describe('VerificationTokenSchema', () => { }); }); +describe('ApiKeySchema', () => { + it('should accept minimal API key', () => { + const key = { + id: 'key_123', + name: 'CI/CD Pipeline', + userId: 'user_123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const result = ApiKeySchema.parse(key); + expect(result.enabled).toBe(true); + }); + + it('should accept full API key with rate limiting and permissions', () => { + const key: ApiKey = { + id: 'key_123', + name: 'Production API Key', + start: 'os_pk_ab', + prefix: 'os_pk_', + userId: 'user_123', + organizationId: 'org_456', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + lastRefetchAt: new Date().toISOString(), + enabled: true, + rateLimitEnabled: true, + rateLimitTimeWindow: 60000, + rateLimitMax: 100, + remaining: 95, + permissions: { 'publish': true, 'read': true, 'manage': false }, + scopes: ['marketplace:publish', 'marketplace:read'], + metadata: { environment: 'production' }, + }; + + const result = ApiKeySchema.parse(key); + expect(result.organizationId).toBe('org_456'); + expect(result.scopes).toHaveLength(2); + expect(result.permissions?.publish).toBe(true); + }); + + it('should accept API key without optional fields', () => { + const key = { + id: 'key_123', + name: 'Minimal Key', + userId: 'user_123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const result = ApiKeySchema.parse(key); + expect(result.organizationId).toBeUndefined(); + expect(result.expiresAt).toBeUndefined(); + expect(result.scopes).toBeUndefined(); + }); + + it('should correctly infer ApiKey type', () => { + const key: ApiKey = { + id: 'key_123', + name: 'Test Key', + userId: 'user_123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + expect(key.id).toBe('key_123'); + expect(key.name).toBe('Test Key'); + }); +}); + describe('Type inference', () => { it('should correctly infer User type', () => { const user: User = { diff --git a/packages/spec/src/identity/identity.zod.ts b/packages/spec/src/identity/identity.zod.ts index 07db7ff1d..cacbf2fba 100644 --- a/packages/spec/src/identity/identity.zod.ts +++ b/packages/spec/src/identity/identity.zod.ts @@ -226,3 +226,112 @@ export const VerificationTokenSchema = z.object({ }); export type VerificationToken = z.infer; + +/** + * API Key Schema + * + * Aligns with better-auth's API key plugin capabilities. + * Provides programmatic access to ObjectStack APIs (CI/CD, service-to-service, CLI). + * + * @see https://www.better-auth.com/docs/plugins/api-key + */ +export const ApiKeySchema = z.object({ + /** + * Unique API key identifier + */ + id: z.string().describe('API key identifier'), + + /** + * Human-readable name for the key + */ + name: z.string().describe('API key display name'), + + /** + * Key prefix (visible portion for identification, e.g., "os_pk_ab") + */ + start: z.string().optional().describe('Key prefix for identification'), + + /** + * Custom prefix for the key (e.g., "os_pk_") + */ + prefix: z.string().optional().describe('Custom key prefix'), + + /** + * User ID of the key owner + */ + userId: z.string().describe('Owner user ID'), + + /** + * Organization ID the key is scoped to (optional) + */ + organizationId: z.string().optional().describe('Scoped organization ID'), + + /** + * Key expiration timestamp (null = never expires) + */ + expiresAt: z.string().datetime().optional().describe('Expiration timestamp'), + + /** + * Creation timestamp + */ + createdAt: z.string().datetime().describe('Creation timestamp'), + + /** + * Last update timestamp + */ + updatedAt: z.string().datetime().describe('Last update timestamp'), + + /** + * Last used timestamp + */ + lastUsedAt: z.string().datetime().optional().describe('Last used timestamp'), + + /** + * Last refetch timestamp (for cached permission checks) + */ + lastRefetchAt: z.string().datetime().optional().describe('Last refetch timestamp'), + + /** + * Whether this key is enabled + */ + enabled: z.boolean().default(true).describe('Whether the key is active'), + + /** + * Rate limiting: enabled flag + */ + rateLimitEnabled: z.boolean().optional().describe('Whether rate limiting is enabled'), + + /** + * Rate limiting: time window in milliseconds + */ + rateLimitTimeWindow: z.number().int().min(0).optional().describe('Rate limit window (ms)'), + + /** + * Rate limiting: max requests per window + */ + rateLimitMax: z.number().int().min(0).optional().describe('Max requests per window'), + + /** + * Rate limiting: remaining requests in current window + */ + remaining: z.number().int().min(0).optional().describe('Remaining requests'), + + /** + * Permissions assigned to this key (granular access control) + */ + permissions: z.record(z.string(), z.boolean()).optional() + .describe('Granular permission flags'), + + /** + * Scopes assigned to this key (high-level access categories) + */ + scopes: z.array(z.string()).optional() + .describe('High-level access scopes'), + + /** + * Custom metadata + */ + metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata'), +}); + +export type ApiKey = z.infer;