diff --git a/apps/api/cloudflare/wrangler.jsonc b/apps/api/cloudflare/wrangler.jsonc index bb353b9..6b65bdf 100644 --- a/apps/api/cloudflare/wrangler.jsonc +++ b/apps/api/cloudflare/wrangler.jsonc @@ -6,6 +6,9 @@ "vars": { "CONTEXTMEM_WORKER_BASE_URL": "https://contextmem-backend.petlofi.workers.dev", "CONTEXTMEM_DEMO_SAMPLE_TARGET": "https://seal-docs.wal.app/", + // Anonymous demo build cap per IP per day. "0" = disabled (showcase mode); + // set to a finite number (e.g. "50") to re-enable abuse protection afterward. + "CONTEXTMEM_DEMO_DAILY_LIMIT": "0", // Grounded-chat model (OpenAI-compatible). These two are NOT secrets (just a // URL + a model name). Set the API key separately as a secret to switch chat // from Workers AI (llama) to OpenRouter: @@ -66,6 +69,7 @@ "vars": { "CONTEXTMEM_WORKER_BASE_URL": "https://contextmem-backend-staging.petlofi.workers.dev", "CONTEXTMEM_DEMO_SAMPLE_TARGET": "https://seal-docs.wal.app/", + "CONTEXTMEM_DEMO_DAILY_LIMIT": "0", "OPENAI_BASE_URL": "https://openrouter.ai/api/v1", "OPENAI_MODEL": "openai/gpt-4o-mini" }, diff --git a/apps/api/src/worker.test.ts b/apps/api/src/worker.test.ts index e3b615b..017843c 100644 --- a/apps/api/src/worker.test.ts +++ b/apps/api/src/worker.test.ts @@ -562,7 +562,7 @@ describe("ContextMeM hosted namespace Worker", () => { }); it("runs public demo extraction with quota, event status, and clear target validation", async () => { - const env = createTestEnv(); + const env = { ...createTestEnv(), CONTEXTMEM_DEMO_DAILY_LIMIT: "1" }; const restoreFetch = mockFetch({ "https://demo-product.wal.app/": "Demo SiteAbout", "https://demo-product.wal.app/about": "AboutDemo about page", @@ -659,6 +659,35 @@ describe("ContextMeM hosted namespace Worker", () => { } }); + it("disables the anonymous demo cap when CONTEXTMEM_DEMO_DAILY_LIMIT is 0 (showcase mode)", async () => { + const env = { ...createTestEnv(), CONTEXTMEM_DEMO_DAILY_LIMIT: "0" }; + const restoreFetch = mockFetch({ + "https://demo-product.wal.app/": "Demo SiteAbout", + "https://demo-product.wal.app/about": "AboutDemo about page", + "https://demo-product.wal.app/robots.txt": "User-agent: *\nAllow: /", + "https://demo-product.wal.app/sitemap.xml": "" + }); + try { + const { handleWorkerRequest } = await worker(); + const build = () => handleWorkerRequest( + new Request("https://contextmem.test/api/demo/extractions", { + method: "POST", + headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.99" }, + body: JSON.stringify({ target: "https://demo-product.wal.app/" }) + }), + env + ); + const first = await build(); + expect(first.status).toBe(202); + const second = await build(); + expect(second.status).toBe(202); // not 429 — cap disabled + const third = await build(); + expect(third.status).toBe(202); + } finally { + restoreFetch(); + } + }); + it("stores feedback and creates redacted public share links", async () => { const env = createTestEnv(); const { handleWorkerRequest, CloudflareNamespaceStore } = await worker(); diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 9e7bc25..b3bf183 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -45,6 +45,10 @@ export type WorkerEnv = { CONTEXTMEM_NAMESPACE_IMPORT_TOKEN?: string; CONTEXTMEM_WORKER_BASE_URL?: string; CONTEXTMEM_DEMO_SAMPLE_TARGET?: string; + // Anonymous demo builds allowed per IP per day. Unset/blank → 50. "0" disables + // the cap entirely (showcase mode). Bypassed for the bundled sample target and + // for any request carrying a delegate (those are never rate-limited). + CONTEXTMEM_DEMO_DAILY_LIMIT?: string; CONTEXTMEM_WEBHOOK_SECRET?: string; MEMWAL_MCP_URL?: string; MEMWAL_API_URL?: string; @@ -705,6 +709,7 @@ async function routeWorkerRequest(request: Request, env: WorkerEnv, ctx: WorkerE if (request.method === "GET" && hostedRunRoute.action === "artifact-file") return getHostedRunArtifactFile(request, env, hostedRunRoute.runId); if (request.method === "GET" && hostedRunRoute.action === "publish-readiness") return getHostedRunPublishReadiness(request, env, hostedRunRoute.runId); if (request.method === "POST" && hostedRunRoute.action === "share") return shareHostedRun(request, env, hostedRunRoute.runId); + if (request.method === "POST" && hostedRunRoute.action === "hosted/import") return importHostedRun(request, env, hostedRunRoute.runId); if (request.method === "POST" && hostedRunRoute.action === "ai-query") return aiQueryRun(request, env, hostedRunRoute.runId); } if (request.method === "POST" && url.pathname === "/api/demo/extractions") { @@ -1429,14 +1434,27 @@ async function getHostedRunArtifactFile(request: Request, env: WorkerEnv, runId: async function getHostedRunPublishReadiness(request: Request, env: WorkerEnv, runId: string): Promise { const job = await requireRunReadAccess(request, env, runId); const [artifact, files] = await Promise.all([artifactManifestForJob(env, job).catch(() => undefined), new CloudflareNamespaceStore(env).listArtifacts(job.namespace)]); - const paths = new Set(files.map((file) => file.path)); - const routeCount = Array.isArray(artifact?.pages) ? artifact.pages.length : 0; + const fileMap = new Map(files.map((file) => [file.path, file])); + // Mirror packages/core buildPublishReadiness so the hosted (Worker) shape matches + // the local (Fastify) shape the PublishPanel expects — required/optional/warnings/commands/files. + const requiredPaths = ["/index.html", "/llms.txt", "/ws-resources.json", "/context/manifest.json", "/context/sitemap.json", "/context/site-structure.json", "/context/images.json"]; + const optionalPaths = ["/context/brand.json", "/context/styleguide.json", "/context/design-system.json", "/context/figma.tokens.json", "/context/tokens.css", "/context/resources.json", "/context/discovery.json", "/context/screenshots.json", "/context/component-previews.json"]; + const required = requiredPaths.map((filePath) => ({ path: filePath, exists: fileMap.has(filePath), size: fileMap.get(filePath)?.size })); + const optional = optionalPaths.map((filePath) => ({ path: filePath, exists: fileMap.has(filePath), size: fileMap.get(filePath)?.size })); + const warnings = required.filter((file) => !file.exists).map((file) => `Missing required package file: ${file.path}`); + if (!optional.some((file) => file.path === "/context/design-system.json" && file.exists)) warnings.push("Design-system export is optional, but absent from this package."); + if (!optional.some((file) => file.path === "/context/screenshots.json" && file.exists)) warnings.push("Screenshot previews are optional, but absent from this package."); + const routeCount = Array.isArray(artifact?.pages) ? artifact.pages.length : files.filter((file) => file.path === "/index.html" || file.path.startsWith("/context/")).length; return json({ - ready: paths.has("/llms.txt") && paths.has("/context/manifest.json"), + ready: required.every((file) => file.exists), routeCount, artifactCount: files.length, totalBytes: files.reduce((sum, file) => sum + Number(file.size || 0), 0), - missing: [!paths.has("/llms.txt") ? "/llms.txt" : undefined, !paths.has("/context/manifest.json") ? "/context/manifest.json" : undefined].filter(Boolean) + required, + optional, + warnings, + commands: { publish: "site-builder publish --epochs 1 ." }, + files: files.map(hostedArtifactFileRecord) }); } @@ -1485,6 +1503,50 @@ async function shareHostedRun(request: Request, env: WorkerEnv, runId: string): return json({ share: shareLinkFromImport(shareId, shareNamespace, shareInput, imported, now, request, env), url: `${workerBaseUrl(request, env)}/share/${shareId}` }, 201); } +// Publish a finished run's package as a hosted MCP namespace (public or private). +// The hosted parallel of the Fastify-only POST /api/runs/:id/hosted/import: it +// existed only on the local dev server, so the prod Worker 404'd this button. Mirrors +// shareHostedRun but writes to the caller-chosen namespace/visibility. Public imports +// are redacted before storage; private imports are Seal-encrypted by storeNamespaceImport. +async function importHostedRun(request: Request, env: WorkerEnv, runId: string): Promise { + const job = await requireHostedRunAccess(request, env, runId); + const input = z + .object({ + namespace: z.string().min(1).max(200).optional(), + visibility: z.enum(["private", "public"]).optional(), + displayName: z.string().max(160).optional(), + description: z.string().max(600).optional(), + tags: z.array(z.string()).optional(), + directoryEnabled: z.boolean().optional() + }) + .parse(await request.json().catch(() => ({}))); + const visibility = input.visibility ?? "private"; + const rawFiles = await namespaceFiles(env, job.namespace); + const files = visibility === "public" ? redactImportFiles(rawFiles) : rawFiles; + const rawManifest = await artifactManifestForJob(env, job).catch(() => ({ target: job.target })); + const result = await storeNamespaceImport( + { + namespace: input.namespace || job.namespace, + visibility, + ownerId: job.ownerId, + displayName: input.displayName ?? job.displayName ?? displayNameFromTarget(job.target), + description: input.description ?? job.description, + tags: input.tags?.length ? input.tags : ["hosted", "contextmem"], + sourceType: "import", + directoryEnabled: visibility === "public" && Boolean(input.directoryEnabled), + target: job.target, + sourceRunId: job.id, + buildKind: job.buildKind, + sources: job.sources, + manifest: visibility === "public" ? redactUnknown(rawManifest) : rawManifest, + files + }, + request, + env + ); + return json(result, 201); +} + async function aiQueryRun(request: Request, env: WorkerEnv, runId: string): Promise { const job = await getExtractionJob(env, runId); if (!job) return jsonError("RUN_NOT_FOUND", "Run not found.", 404); @@ -4669,13 +4731,22 @@ function isPrivateIpv4(host: string): boolean { return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0; } +function demoDailyLimit(env: WorkerEnv): number { + const raw = env.CONTEXTMEM_DEMO_DAILY_LIMIT; + if (raw === undefined || raw === "") return 50; + const n = Number(raw); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 50; +} + async function consumeDemoQuota(request: Request, env: WorkerEnv): Promise { + const limit = demoDailyLimit(env); + if (limit <= 0) return; // 0 disables the anonymous demo cap entirely (showcase mode) const ip = clientIp(request); const day = new Date().toISOString().slice(0, 10); const ipHash = await sha256Hex(ip); const key = `${day}:${ipHash}`; const existing = await env.CONTEXTMEM_DB.prepare(`SELECT bucket_key, count FROM contextmem_demo_limits WHERE bucket_key = ?`).bind(key).first<{ bucket_key: string; count: number }>(); - if (existing && Number(existing.count) >= 1) throw statusError("Demo limit reached for today. Import MemWal credentials to unlock unlimited builds.", 429, "DEMO_LIMIT_EXCEEDED", "Open /app/settings, paste your MemWal account ID and delegate private key, then run the build again — the demo quota is bypassed once the delegate is attached."); + if (existing && Number(existing.count) >= limit) throw statusError(`Demo limit reached for today (${limit}/day). Import MemWal credentials to unlock unlimited builds.`, 429, "DEMO_LIMIT_EXCEEDED", "Open /app/settings, paste your MemWal account ID and delegate private key, then run the build again — the demo quota is bypassed once the delegate is attached."); const now = new Date().toISOString(); await env.CONTEXTMEM_DB.prepare( `INSERT INTO contextmem_demo_limits (bucket_key, ip_hash, day, count, updated_at) diff --git a/apps/web/index.html b/apps/web/index.html index 5f4091c..6d57def 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -14,6 +14,9 @@ + + +
diff --git a/apps/web/src/components/blocks/navigation-10.tsx b/apps/web/src/components/blocks/navigation-10.tsx index 513beab..99581c7 100644 --- a/apps/web/src/components/blocks/navigation-10.tsx +++ b/apps/web/src/components/blocks/navigation-10.tsx @@ -42,9 +42,13 @@ export default function Navigation10({
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d95bd8a..44c80c0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1392,28 +1392,53 @@ function ContextMemExperience() { let body = (await response.json()) as { job: HostedExtractionJob; demo?: { remainingToday?: number } }; lastJob = body.job; setDemoPreview(demoPreviewStateFromJob(body.job, displayTarget)); - for (let attempt = 0; attempt < 20 && (body.job.status === "queued" || body.job.status === "running"); attempt += 1) { - await delay(800); + // Prod runs the build async on a Worker queue, so a real Walrus Site can + // take a couple of minutes. Poll ~3 min (200 × 900ms) before handing off + // to a graceful "still building" state instead of falsely erroring. + for (let attempt = 0; attempt < 200 && (body.job.status === "queued" || body.job.status === "running"); attempt += 1) { + await delay(900); const status = await fetch(`${API_BASE}/api/demo/extractions/${encodeURIComponent(body.job.id)}`); if (!status.ok) throw new Error(await readResponseError(status)); body = (await status.json()) as { job: HostedExtractionJob }; lastJob = body.job; setDemoPreview(demoPreviewStateFromJob(body.job, displayTarget)); } + if (body.job.status === "failed") throw new Error(body.job.error ?? "Context build failed."); const shareId = (body.job.result as { share?: { id?: string } } | undefined)?.share?.id; - if (!shareId) throw new Error(body.job.error ?? "Demo extraction finished without a share page."); - setDemoPreview({ - phase: "completed", - target: body.job.target || displayTarget, - jobId: body.job.id, - shareId, - message: "Preview ready. Opening share page", - updatedAt: Date.now() - }); - navigate(`/share/${shareId}`); - window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: "smooth" })); + if (shareId) { + setDemoPreview({ + phase: "completed", + target: body.job.target || displayTarget, + jobId: body.job.id, + shareId, + message: "Preview ready. Opening share page", + updatedAt: Date.now() + }); + navigate(`/share/${shareId}`); + window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: "smooth" })); + return; + } + if (body.job.status === "queued" || body.job.status === "running") { + // Our local poll budget ran out but the Worker is still building — don't + // surface this as a failure. The share page will appear shortly. + setDemoPreview({ + phase: "queued", + target: body.job.target || displayTarget, + jobId: body.job.id, + message: "Still building — larger sites take a couple of minutes. Your share page will be ready shortly; reload or check Runs.", + updatedAt: Date.now() + }); + setAuthHint("Still building on the Worker — larger sites take a couple of minutes. The share page will appear shortly."); + return; + } + throw new Error(body.job.error ?? "Demo extraction finished without a share page."); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const raw = err instanceof Error ? err.message : String(err); + // Placeholder hosts (example.com/.org/.net) come back tagged from the + // Worker — replace the noisy tagged message with one clean line. + const message = raw.includes("DEMO_PLACEHOLDER_HOST") + ? "That looks like a placeholder URL (example.com). Try a real Walrus Site — e.g. https://fmsprint.wal.app/ — or a public product URL." + : raw; setDemoPreview({ phase: "failed", target: lastJob?.target || displayTarget, @@ -1489,13 +1514,17 @@ function ContextMemExperience() { /> ); - function renderShell(pageTitle: string, pageDescription: string, child: React.ReactNode) { + function renderShell(pageTitle: string, pageDescription: string, child: React.ReactNode, allowAnon = false) { const lockedContent = ( <> {demoPreview ? openApp("/")} /> : null} {lockScreen} ); + // Read-only pages (namespace gallery, knowledge-graph explorer) hit only public + // GET endpoints, so they render for anonymous visitors. Write/build/settings pages + // stay gated on a delegate. + const unlocked = allowAnon || hasMemWalDelegate; return ( - {hasMemWalDelegate ? {child} : lockedContent} + {unlocked ? {child} : lockedContent} ); } @@ -1537,7 +1567,8 @@ function ContextMemExperience() { onHeroMouseMove={handleHeroMouseMove} onHeroMouseLeave={resetHeroMotion} onHeroAction={startRunFromLanding} - onOpenApp={() => openApp("/app")} + onOpenApp={() => openApp("/app/home")} + onImportDelegate={() => openApp("/app/settings")} onInspectArtifacts={() => openApp("/app/artifacts")} onOpenHistory={() => openApp("/app/runs")} /> @@ -1545,6 +1576,7 @@ function ContextMemExperience() { /> } /> } /> + , true)} /> + , + true )} /> )} /> )} /> - )} /> + , true)} /> +