Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/content/docs/open-source-credits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ and isolated live component runtime.
| [Pierre Diffs](https://github.com/pierrecomputer/pierre/tree/main/packages/diffs) (`@pierre/diffs`) | Apache-2.0 | Local curator source diffs; unpublished source is not sent to a third party. |
| [Pierre Theme](https://github.com/pierrecomputer/pierre/tree/main/packages/theme) (`@pierre/theme`) | MIT | Light, dark, and color-vision-safe code palettes. |
| [Shiki](https://shiki.style/) (`shiki`) | MIT | Syntax tokenization and highlighted source rendering. |
| [Sucrase](https://github.com/alangpierce/sucrase) (`sucrase`) | MIT | Server-side TypeScript/JSX compilation for paid preview builds. |
| [Terser](https://terser.org/) (`terser`) | BSD-2-Clause | Server-side minification and mangling of paid preview builds. |
| [React CodeMirror](https://uiwjs.github.io/react-codemirror/) (`@uiw/react-codemirror`) | MIT | React integration for the source editor. |
| [CodeMirror GitHub theme](https://uiwjs.github.io/react-codemirror/#/theme/data/github/light) (`@uiw/codemirror-theme-github`) | MIT | Base editor theme adapter; Modulora maps it to Pierre tokens. |
| [CodeMirror CSS language](https://github.com/codemirror/lang-css) (`@codemirror/lang-css`) | MIT | CSS parsing and editor language support. |
Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@
"react-icons": "^5.7.0",
"shiki": "^4.3.1",
"stripe": "^22.3.1",
"tailwind-merge": "^3.3.1"
"sucrase": "^3.35.1",
"tailwind-merge": "^3.3.1",
"terser": "^5.49.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.2.1",
Expand Down
30 changes: 17 additions & 13 deletions apps/web/src/components/collection-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,33 +132,37 @@ export function CollectionView({ collection }: { collection: CollectionDetail })
</nav>

<div className={`overflow-hidden rounded-xl border border-border/60 ${pageTheme === "dark" ? "bg-[#0d0d0d]" : "bg-[#f5f5f3]"}`}>
{member.locked ? (
{(member.files ?? []).length > 0 ? (
<ComponentSandbox
key={member.name}
files={(member.files ?? []).map((f) => ({ path: f.path, content: f.content }))}
selectedDemo={demoPath}
theme={pageTheme}
className="h-[32rem]"
/>
) : (
<div className="flex h-[32rem] flex-col items-center justify-center gap-3 text-center">
<span className="flex size-11 items-center justify-center rounded-full border border-white/15 bg-black/60">
<FileLock2 className="size-5" />
</span>
<div>
<p className="font-semibold">Purchase to preview</p>
<p className="font-semibold">Preview unavailable</p>
<p className="mt-1 max-w-xs text-sm text-muted-foreground">
{member.title} is a paid component — buying the collection unlocks it.
{member.title} could not be previewed — buying the collection unlocks the source and install.
</p>
</div>
</div>
) : (
<ComponentSandbox
key={member.name}
files={(member.files ?? []).map((f) => ({ path: f.path, content: f.content }))}
selectedDemo={demoPath}
theme={pageTheme}
className="h-[32rem]"
/>
)}
{!member.locked ? (
{member.locked ? (
<div className="flex items-center gap-2 border-t border-border/60 px-4 py-2.5 text-xs text-muted-foreground">
<FileLock2 className="size-3.5 shrink-0" /> Live preview only — buying the collection unlocks the readable source and install.
</div>
) : (
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-2.5">
<code className="truncate font-mono text-xs text-muted-foreground">{memberCommand}</code>
<CopyChip label="Copy" text={memberCommand} icon={TerminalSquare} />
</div>
) : null}
)}
</div>
</div>
</div>
Expand Down
97 changes: 51 additions & 46 deletions apps/web/src/lib/catalog-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { normalizeDomain } from "./domains";
import { hasCollectionEntitlement, hasEntitlement } from "./marketplace";
import { DIRECT_MARKETPLACE_ENABLED } from "./flags";
import { licenseTemplate, resolveLicenseText } from "./license";
import { obfuscatePreviewFiles, requiresCompiledPreview } from "./preview-obfuscate";
import { publicListsFor } from "./lists";
import {
visibleProfileContent,
Expand Down Expand Up @@ -246,29 +247,28 @@ export const fetchCatalogDetail = createServerFn({ method: "GET" })
if (!isOwner && !viewer?.isCurator) return null;
}

// Marketplace pricing: an active price gates the source behind purchase.
const price = DIRECT_MARKETPLACE_ENABLED
? (await database
.select({
unitAmount: schema.componentPrices.unitAmount,
licenseTemplate: schema.componentPrices.licenseTemplate,
licenseText: schema.componentPrices.licenseText,
})
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, row.component.id), eq(schema.componentPrices.active, true)))
.limit(1))[0]
: undefined;
const marketplacePrice = price?.unitAmount ?? null;
const marketplaceLicense = price
// An active price protects source regardless of whether direct checkout is
// enabled. The feature flag controls commerce UI, never access control.
const protectionPrice = (await database
.select({
unitAmount: schema.componentPrices.unitAmount,
licenseTemplate: schema.componentPrices.licenseTemplate,
licenseText: schema.componentPrices.licenseText,
})
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, row.component.id), eq(schema.componentPrices.active, true)))
.limit(1))[0];
const marketplacePrice = DIRECT_MARKETPLACE_ENABLED ? (protectionPrice?.unitAmount ?? null) : null;
const marketplaceLicense = DIRECT_MARKETPLACE_ENABLED && protectionPrice
? {
name: licenseTemplate(price.licenseTemplate).name,
text: resolveLicenseText(price.licenseTemplate, price.licenseText),
name: licenseTemplate(protectionPrice.licenseTemplate).name,
text: resolveLicenseText(protectionPrice.licenseTemplate, protectionPrice.licenseText),
}
: null;

// The viewer's own purchase (buyer side): powers the "You own this" tray.
let ownedPurchase: import("./purchases").OwnedComponent | null = null;
if (marketplacePrice !== null && viewer) {
if (protectionPrice && viewer) {
const [p] = await database
.select({
id: schema.purchases.id,
Expand Down Expand Up @@ -296,10 +296,9 @@ export const fetchCatalogDetail = createServerFn({ method: "GET" })
};
}
}
const entitled =
marketplacePrice === null
? true
: await hasEntitlement(row.component.id, viewer?.id ?? null, row.ownerUserId ?? null);
const entitled = !protectionPrice
? true
: await hasEntitlement(row.component.id, viewer?.id ?? null, row.ownerUserId ?? null);

const files = row.version
? await database
Expand All @@ -310,12 +309,16 @@ export const fetchCatalogDetail = createServerFn({ method: "GET" })
: [];
const evidence = row.version ? await loadEvidence(database, row.version.id) : [];

// Never send paid source to a viewer who hasn't purchased it. Unentitled
// viewers get a compiled preview artifact (or nothing if compilation fails).
const readable = files.map((file) => ({ path: file.path, content: file.content ?? "" }));
const previewOnly = requiresCompiledPreview(row.component.sourceModel, entitled);
const shippedFiles = previewOnly ? (await obfuscatePreviewFiles(readable)) ?? [] : readable;
const item = toCatalogItem(
row.namespace,
row.component,
row.version,
// Never send paid source to a viewer who hasn't purchased it.
entitled ? files.map((file) => ({ path: file.path, content: file.content ?? "" })) : [],
shippedFiles,
evidence,
);
// Record the view: approved + public only, never the owner's own visits.
Expand Down Expand Up @@ -921,14 +924,13 @@ export const fetchCollectionDetail = createServerFn({ method: "GET" })
const request = getRequest();
const viewer = request ? await getCurrentUser(request) : null;

const price = DIRECT_MARKETPLACE_ENABLED
? (await database
.select({ unitAmount: schema.collectionPrices.unitAmount, licenseTemplate: schema.collectionPrices.licenseTemplate, licenseText: schema.collectionPrices.licenseText })
.from(schema.collectionPrices)
.where(and(eq(schema.collectionPrices.collectionId, row.collection.id), eq(schema.collectionPrices.active, true)))
.limit(1))[0]
: undefined;
const owned = price
const protectionPrice = (await database
.select({ unitAmount: schema.collectionPrices.unitAmount, licenseTemplate: schema.collectionPrices.licenseTemplate, licenseText: schema.collectionPrices.licenseText })
.from(schema.collectionPrices)
.where(and(eq(schema.collectionPrices.collectionId, row.collection.id), eq(schema.collectionPrices.active, true)))
.limit(1))[0];
const price = DIRECT_MARKETPLACE_ENABLED ? protectionPrice : undefined;
const owned = protectionPrice
? await hasCollectionEntitlement(row.collection.id, viewer?.id ?? null, row.ownerUserId)
: false;
const externalDomain = row.collection.externalUrl ? domainOf(row.collection.externalUrl) : null;
Expand Down Expand Up @@ -956,25 +958,28 @@ export const fetchCollectionDetail = createServerFn({ method: "GET" })
const members: (CatalogItem & { locked: boolean })[] = [];
for (const member of memberRows) {
if (member.component.visibility !== "public" || member.component.reviewStatus !== "approved" || !member.version) continue;
const memberPrice = DIRECT_MARKETPLACE_ENABLED
? (await database
.select({ id: schema.componentPrices.id })
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, member.component.id), eq(schema.componentPrices.active, true)))
.limit(1))[0]
: undefined;
const memberPrice = (await database
.select({ id: schema.componentPrices.id })
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, member.component.id), eq(schema.componentPrices.active, true)))
.limit(1))[0];
const entitled = !memberPrice
? true
: owned || (await hasEntitlement(member.component.id, viewer?.id ?? null, row.ownerUserId));
const files = entitled
? await database
.select({ path: schema.componentFiles.path, content: schema.componentFiles.content })
.from(schema.componentFiles)
.where(eq(schema.componentFiles.componentVersionId, member.version.id))
.orderBy(schema.componentFiles.orderIndex)
: [];
const files = await database
.select({ path: schema.componentFiles.path, content: schema.componentFiles.content })
.from(schema.componentFiles)
.where(eq(schema.componentFiles.componentVersionId, member.version.id))
.orderBy(schema.componentFiles.orderIndex);
// Paid members still render a live preview: unentitled viewers get the
// compiled artifact, never the readable source (nothing on failure).
const readableMemberFiles = files.map((f) => ({ path: f.path, content: f.content ?? "" }));
const previewOnly = requiresCompiledPreview(member.component.sourceModel, entitled);
const shippedMemberFiles = previewOnly
? (await obfuscatePreviewFiles(readableMemberFiles)) ?? []
: readableMemberFiles;
members.push({
...toCatalogItem(data.namespace, member.component, member.version, files.map((f) => ({ path: f.path, content: f.content ?? "" }))),
...toCatalogItem(data.namespace, member.component, member.version, shippedMemberFiles),
locked: !entitled,
});
}
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/lib/preview-obfuscate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Preview builds for paid source. Unentitled viewers can render a live
* preview, but they receive a compiled artifact — types and comments are
* destroyed and local identifiers mangled server-side. This is lossy
* compilation, not keyed encryption: Modulora being open source does not
* weaken it, exactly as reading a minifier's source cannot un-minify its
* output. The readable source never leaves the server without entitlement,
* and any transform failure fails closed (no files at all).
*/
import { transform } from "sucrase";
import { minify } from "terser";

export interface PreviewFile {
path: string;
content: string;
}

/** Paid external source and unentitled hosted source are preview-only. */
export function requiresCompiledPreview(sourceModel: string, entitled: boolean): boolean {
return sourceModel !== "open-source" || !entitled;
}

const BANNER = "/* Modulora preview build — purchase unlocks the readable source. */\n";
const SCRIPT_EXTENSIONS = /\.(tsx|ts|jsx|js|mjs)$/i;

async function compileOne(file: PreviewFile): Promise<PreviewFile> {
if (!SCRIPT_EXTENSIONS.test(file.path)) return file;
if (file.content.startsWith(BANNER)) return file;
const stripped = transform(file.content, {
transforms: ["typescript", "jsx"],
jsxRuntime: "automatic",
production: true,
filePath: file.path,
}).code;
const minified = await minify(stripped, {
module: true,
compress: { defaults: true, passes: 2 },
mangle: { toplevel: false },
format: { comments: false },
});
if (!minified.code) throw new Error(`empty terser output for ${file.path}`);
return { path: file.path, content: `${BANNER}${minified.code}` };
}

/**
* Compile every script file for an unentitled preview. Returns null when any
* file cannot be compiled — callers must treat null as "ship nothing".
*/
export async function obfuscatePreviewFiles(files: PreviewFile[]): Promise<PreviewFile[] | null> {
try {
return await Promise.all(files.map(compileOne));
} catch (error) {
console.error("preview obfuscation failed; withholding files", error);
return null;
}
}
29 changes: 16 additions & 13 deletions apps/web/src/lib/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { POLICY_VERSION } from "./publishing-policy";
import { roleFor } from "./scaffold";
import { stripSrc } from "./registry";
import { contentDigest } from "./digest";
import { obfuscatePreviewFiles } from "./preview-obfuscate";

const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
const ALLOWED_EXTENSIONS = new Set(["tsx", "ts", "jsx", "js", "css", "json"]);
Expand Down Expand Up @@ -176,6 +177,10 @@ export async function publishCore(data: PublishInput, request: Request): Promise
}

const isPaid = data.pricing === "paid";
// Paid external listings keep only a lossy compiled preview artifact.
// Readable paid source must never be persisted by Modulora.
const previewFiles = isPaid ? await obfuscatePreviewFiles(files) : files;
if (!previewFiles) return { ok: false, error: "Could not build the paid preview. Check the component and demo files." };
const purchaseUrl = String(data.purchaseUrl ?? "").trim();
let purchaseDomain: string | null = null;
let purchaseDomainVerified = false;
Expand Down Expand Up @@ -346,19 +351,17 @@ export async function publishCore(data: PublishInput, request: Request): Promise
})
.returning({ id: schema.componentVersions.id });

if (!isPaid) {
await db.insert(schema.componentFiles).values(
files.map((file, index) => ({
componentVersionId: createdVersion!.id,
path: file.path.trim(),
fileType: "registry:component",
role: roleFor(file.path.trim()),
content: file.content,
sizeBytes: new TextEncoder().encode(file.content).length,
orderIndex: index,
})),
);
}
await db.insert(schema.componentFiles).values(
previewFiles.map((file, index) => ({
componentVersionId: createdVersion!.id,
path: file.path.trim(),
fileType: "registry:component",
role: roleFor(file.path.trim()),
content: file.content,
sizeBytes: new TextEncoder().encode(file.content).length,
orderIndex: index,
})),
);

// Record honest, scoped evidence for this exact release. Every record is
// something we can actually prove — no fabricated "signed"/"verified" badges.
Expand Down
37 changes: 15 additions & 22 deletions apps/web/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { and, eq } from "drizzle-orm";
import { schema } from "@modulora/db";
import { getCurrentUser } from "./session";
import { hasCollectionEntitlement, hasEntitlement } from "./marketplace";
import { DIRECT_MARKETPLACE_ENABLED } from "./flags";

export interface ParsedRegistryPath {
namespace: string;
Expand Down Expand Up @@ -94,13 +93,11 @@ async function resolveCollection(

// A priced collection is a product: the whole bundle requires the bundle
// entitlement (buying it also snapshots per-member entitlements).
const bundlePrice = DIRECT_MARKETPLACE_ENABLED
? (await db
.select({ unitAmount: schema.collectionPrices.unitAmount, currency: schema.collectionPrices.currency })
.from(schema.collectionPrices)
.where(and(eq(schema.collectionPrices.collectionId, collection.collection.id), eq(schema.collectionPrices.active, true)))
.limit(1))[0]
: undefined;
const bundlePrice = (await db
.select({ unitAmount: schema.collectionPrices.unitAmount, currency: schema.collectionPrices.currency })
.from(schema.collectionPrices)
.where(and(eq(schema.collectionPrices.collectionId, collection.collection.id), eq(schema.collectionPrices.active, true)))
.limit(1))[0];
if (bundlePrice) {
const bundleViewer = request ? await getCurrentUser(request) : null;
const [bundleOwner] = await db
Expand Down Expand Up @@ -147,13 +144,11 @@ async function resolveCollection(

for (const member of servable) {
const channels = member.component.distributionChannels ?? [];
const price = DIRECT_MARKETPLACE_ENABLED
? (await db
.select({ id: schema.componentPrices.id })
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, member.component.id), eq(schema.componentPrices.active, true)))
.limit(1))[0]
: undefined;
const price = (await db
.select({ id: schema.componentPrices.id })
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, member.component.id), eq(schema.componentPrices.active, true)))
.limit(1))[0];
const paid = Boolean(price);
const entitled = !paid || (await hasEntitlement(member.component.id, viewer?.id ?? null, owner?.ownerUserId ?? null));
if (!entitled) {
Expand Down Expand Up @@ -253,13 +248,11 @@ export async function resolveRegistryItem(

// Marketplace-priced components require an entitlement: the buyer (or the
// owner), authenticated via session cookie or a CLI bearer token.
const price = DIRECT_MARKETPLACE_ENABLED
? (await db
.select({ unitAmount: schema.componentPrices.unitAmount, currency: schema.componentPrices.currency })
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, c.id), eq(schema.componentPrices.active, true)))
.limit(1))[0]
: undefined;
const price = (await db
.select({ unitAmount: schema.componentPrices.unitAmount, currency: schema.componentPrices.currency })
.from(schema.componentPrices)
.where(and(eq(schema.componentPrices.componentId, c.id), eq(schema.componentPrices.active, true)))
.limit(1))[0];
if (price) {
const viewer = request ? await getCurrentUser(request) : null;
const entitled = await hasEntitlement(c.id, viewer?.id ?? null, row.ownerUserId ?? null);
Expand Down
Loading
Loading