diff --git a/README.md b/README.md index 6d81a36..92dae3d 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **Don't meet. Meld.** -meld puts the context on a URL so neither side has to paste the block. Then the URL dies. +meld puts the context on a capability URL so neither side has to paste the block. The URL is the shared bearer for the exchange. It dissolves on the timer you choose. -One URL. Both sides add context. When it resolves, the host serves 410 and the meld is gone. No history, no threads, no accounts. +One URL. Both sides add context. Pick 3 minutes, 1 hour, or 1 day. When that timer ends, the host serves 410 and the bridge is gone. No history, no threads, no accounts. ## Live @@ -30,7 +30,7 @@ https://meld.mergeinc.workers.dev # Create a meld — get one share URL curl -X POST https://meld.mergeinc.workers.dev/api/melds \ -H "Content-Type: application/json" \ - -d '{"context": "Auth flow: OAuth2+PKCE, JWT tokens, refresh rotation"}' + -d '{"context": "Auth flow: OAuth2+PKCE, JWT tokens, refresh rotation", "ttl": "1hr"}' # → {"code": "abc123", "url": "https://…/m/abc123", ...} # Party B (human or agent) resolves: @@ -49,7 +49,7 @@ curl https://meld.mergeinc.workers.dev/api/melds/abc123 ## Trust model -Capability URL + TTL. The host is readable while live, and anyone with the link can read it. Not for secrets/credentials/regulated. The meld dissolves on TTL. Mint-next means: create another meld URL and put it in the reply. Full statement: [TRUST.md](TRUST.md). +Capability URL + TTL. The host is readable while live, and anyone with the link can read it. Not for secrets/credentials/regulated. Create requires a bridge time: `3m`, `1hr`, or `1d`. The server enforces that TTL. Full statement: [TRUST.md](TRUST.md). ## API @@ -64,4 +64,4 @@ Capability URL + TTL. The host is readable while live, and anyone with the link ## Pricing -Humans are free in the browser. Agents get 3 creates/IP/hour, then can use `POST /v1/keys` or wait. No subscriptions. Playbooks: https://meld.mergeinc.workers.dev/recipes.md. +Pilot bridges are free. No payment is required. Create requires `ttl`: `3m` (3 minutes), `1hr` (1 hour), or `1d` (1 day). Per-minute abuse limits still apply. Playbooks: https://meld.mergeinc.workers.dev/recipes.md. diff --git a/TRUST.md b/TRUST.md index 111938b..bb763be 100644 --- a/TRUST.md +++ b/TRUST.md @@ -1,10 +1,10 @@ # meld — trust model -- **Capability URL + TTL.** The URL grants access while the meld is live. -- **Host-readable while live.** -- **Anyone with the link can read it.** -- **Not for secrets/credentials/regulated.** -- **Dissolves on TTL.** Unresolved melds last up to one hour; resolved melds about ten minutes. -- **Mint-next.** Create another meld URL and put it in the reply. +- Capability URL + TTL: the URL grants access while the meld is live. +- Host-readable while live. +- Anyone with the link can read it. +- Not for secrets/credentials/regulated. +- Dissolves on the TTL chosen at create: 3 minutes (`3m`), 1 hour (`1hr`), or 1 day (`1d`). +- Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default. -meld is an ephemeral context handoff, not a vault. The host stores ordinary context only for the live TTL, then deletes the meld. There are no accounts or long-term content archives. Use meld for ordinary, disposable handoffs only. +The host stores ordinary context for the live TTL and deletes the meld after expiry. There are no accounts or long-term content archives. Rate-limit identity is IP-based. Use meld for ordinary, disposable handoffs only. diff --git a/deploy/AGENTS-root.md b/deploy/AGENTS-root.md index 1326454..000d272 100644 --- a/deploy/AGENTS-root.md +++ b/deploy/AGENTS-root.md @@ -1,17 +1,39 @@ # AGENTS.md — working with meld -meld is a capability URL + TTL for a one-time context handoff. The host is readable while live; anyone with the link can read it. Not for secrets/credentials/regulated. The meld dissolves on TTL. +meld puts context on a capability URL with a TTL. The host is readable while live, and anyone with the link can read it. After TTL, the meld dissolves and the host serves 410. Not for secrets, credentials, or regulated data. -## Flow +## Quick start ```bash +# Create; share url. Keep owner_token only for the legacy /result read. curl -s https://meld.mergeinc.workers.dev/api/melds \ -H 'content-type: application/json' -H 'X-Meld-Client: agent' \ - -d '{"context":"..."}' -# share .url, then resolve with the returned .code + -d '{"context":"...","ttl":"1hr"}' +# -> {code, url, owner_url, owner_token, expires_at} + +# Resolve from the link. curl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \ -H 'content-type: application/json' -d '{"context":"..."}' + +# Anyone holding the capability URL can read the live contexts. curl -s https://meld.mergeinc.workers.dev/api/melds/{code} ``` -Mint-next means: create another meld URL and put it in the reply. MCP: https://meld.mergeinc.workers.dev/mcp +## Locked claims + +- Capability URL + TTL. +- Host-readable while live. +- Anyone with the link can read it. +- Not for secrets/credentials/regulated. +- Dissolves on TTL. +- Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default. + +## Agent-to-agent + +Create, send the share URL, resolve once, then read the URL. Pass `ttl` as `3m`, `1hr`, or `1d`. There is no default. MCP: https://meld.mergeinc.workers.dev/mcp + +## Limits and docs + +Pilot bridges are free. Create requires `ttl`: `3m`, `1hr`, or `1d`. Per-minute limits apply to everyone. Errors: 400, 403 PIN, 404, 409, 410, 429. + +Machine-readable docs: /llms.txt · /agents.md · /recipes.md · /openapi.json · /trust.md diff --git a/deploy/agents_content.py b/deploy/agents_content.py index f4493e1..851a6d4 100644 --- a/deploy/agents_content.py +++ b/deploy/agents_content.py @@ -1,35 +1,4 @@ from html import escape -AGENTS_MD = '''# meld — agent API - -meld is a capability URL + TTL for one context exchange. The host is readable while live; anyone with the link can read it. Not for secrets/credentials/regulated. The meld dissolves on TTL. - -## Create -> resolve -> read - -```bash -curl -s https://meld.mergeinc.workers.dev/api/melds \\ - -H 'content-type: application/json' -H 'X-Meld-Client: agent' \\ - -d '{"context":"What architecture fits 10M users?"}' -# share the returned .url and note .code -curl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\ - -H 'content-type: application/json' \\ - -d '{"context":"Event-driven services plus a queue."}' -curl -s https://meld.mergeinc.workers.dev/api/melds/{code} -``` - -The capability URL is the access. `owner_token` and `/result` remain as a legacy owner-read path. Resolve is one answer; identical retries are idempotent and a conflicting answer returns 409. - -## Mint-next - -If more context is needed, mint-next means: create another meld URL and put it in the reply. - -```bash -next=$(curl -s https://meld.mergeinc.workers.dev/api/melds \\ - -H 'content-type: application/json' -H 'X-Meld-Client: agent' \\ - -d '{"context":"Follow-up: ..."}') -# Put next.url in the reply. -``` - -MCP remote: https://meld.mergeinc.workers.dev/mcp · Recipes: /recipes.md · OpenAPI: /openapi.json -''' -AGENTS_HTML = 'meld — agent API
'+escape(AGENTS_MD)+'
' +AGENTS_MD = '# meld — agent API\n\nmeld is a capability URL + TTL for one context exchange. Host-readable while live; anyone with the link can read it. Not for secrets/credentials/regulated. The meld dissolves on TTL.\n\n## Create -> resolve -> read\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"What architecture fits 10M users?","ttl":"1hr"}\'\n# share the returned .url and note .code\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' \\\n -d \'{"context":"Event-driven services plus a queue."}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\nThe capability URL is the access. `owner_token` and `/result` remain as a legacy owner-read path. Resolve is one answer; identical retries are idempotent and a conflicting answer returns 409.\n\n## Bridge time\n\n`ttl` is required on create and must be `3m`, `1hr`, or `1d`. The server enforces that lifetime. There is no default. Pilot creates are free.\n\nMCP remote: https://meld.mergeinc.workers.dev/mcp · Recipes: /recipes.md · OpenAPI: /openapi.json\n' +AGENTS_HTML = 'meld — agent API
'+escape(AGENTS_MD)+'
' diff --git a/deploy/app_content.py b/deploy/app_content.py index 822bb68..5539ff0 100644 --- a/deploy/app_content.py +++ b/deploy/app_content.py @@ -1 +1 @@ -APP_HTML = '\n\n\n\nmeld — context bridge to an agent\n\n
\n' +APP_HTML = '\n\n\n\n\nmeld — timed bridge\n\n\n\n\n
\n
\n
meld timed bridge
\n \n
\n
\n \n \n
\n
\n
\n\n\n\n' diff --git a/deploy/pyproject.toml b/deploy/pyproject.toml index 253b762..fb4f419 100644 --- a/deploy/pyproject.toml +++ b/deploy/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "meld" version = "1.0.0" -description = "Ephemeral zero-knowledge context bridge" +description = "Timed capability-URL context bridge" requires-python = ">=3.13" dependencies = [ "fastapi", diff --git a/deploy/spa.html b/deploy/spa.html index 6a3e860..c4e0e5c 100644 --- a/deploy/spa.html +++ b/deploy/spa.html @@ -1,24 +1,393 @@ - -meld — context bridge to an agent + + +meld — timed bridge + -
+ + +
+
+
meld timed bridge
+ +
+
+ + +
+
+
\ No newline at end of file +const API = location.origin; +let code = null; +let picked = ""; +let watching = false; +let watchTimer = null; +// One read every 10s is 6/min, under the 60/min view limit. No new auth. +const esc = s => { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; }; +async function api(method, path, body, headers = {}) { + const r = await fetch(API + path, { + method, + headers: { "Content-Type": "application/json", "X-Meld-Client": "human", ...headers }, + body: body ? JSON.stringify(body) : undefined + }); + const d = await r.json().catch(() => ({ detail: r.statusText })); + if (!r.ok) throw Error(d.detail || String(r.status)); + return d; +} +function setSteps(active) { + document.getElementById("creator-rail").classList.remove("off"); + document.getElementById("receiver-rail").classList.add("off"); + for (let i = 1; i <= 4; i++) { + const el = document.getElementById("s" + i); + el.classList.toggle("on", i === active); + el.classList.toggle("done", i < active || active > 4); + } +} +function setReceiverJob(active) { + document.getElementById("creator-rail").classList.add("off"); + document.getElementById("receiver-rail").classList.remove("off"); + for (let i = 1; i <= 3; i++) { + const el = document.getElementById("r" + i); + el.classList.toggle("on", i === active); + el.classList.toggle("done", i < active || active > 3); + } +} +function trustBlock(forOpener) { + const last = forOpener + ? "The bridge dissolves when its timer runs out. Then the link is gone." + : "The bridge dissolves when the timer you choose runs out. Then the link is gone."; + return '
Read this before you put text on the bridge.
"; +} +function needsBlock() { + return '
' + + '
The handoffThe working context the other person or agent should read.
' + + '
A bridge timeOnly three: 3 minutes, 1 hour, or 1 day.
' + + '
The capability URLSharing the link is what lets the other side in.
' + + '
A reply on that linkThey open it, answer, and you read the reply here.
' + + "
"; +} +function landing() { + stopWatch(); + document.title = "meld — timed bridge"; + setSteps(1); + root.innerHTML = '

Open a timed bridge

' + + '

Hand one piece of working context to another person or agent. You get a capability URL. That URL is the shared bearer for the exchange, and it dies on the timer you pick.

' + + needsBlock() + + '' + + '' + + '

This text is what they see when they open the link. Leave out secrets.

' + + '' + + '
' + + timeBtn("3m", "3 minutes", "For a quick handoff. Dissolves in 3 minutes.") + + timeBtn("1hr", "1 hour", "For a working session. Dissolves in 1 hour.") + + timeBtn("1d", "1 day", "For a handoff that can wait. Dissolves in 1 day.") + + "
" + + '

Choose one. Nothing is selected until you do. There is no other duration.

' + + trustBlock() + + '
'; + const ctx = document.getElementById("ctx"); + const count = document.getElementById("count"); + ctx.addEventListener("input", () => { + count.textContent = ctx.value.length + " / 100000"; + setSteps(ctx.value.trim() && picked ? 3 : ctx.value.trim() ? 2 : 1); + }); + document.querySelectorAll(".time").forEach(btn => { + btn.addEventListener("click", () => selectTime(btn.dataset.ttl)); + }); + document.getElementById("create").addEventListener("click", createBridge); +} +function timeBtn(ttl, title, detail) { + return ''; +} +function selectTime(ttl) { + picked = ttl; + document.querySelectorAll(".time").forEach(btn => btn.setAttribute("aria-checked", btn.dataset.ttl === ttl ? "true" : "false")); + const labels = { "3m": "3 minutes", "1hr": "1 hour", "1d": "1 day" }; + document.getElementById("time-hint").textContent = "Selected: " + labels[ttl] + ". The server will dissolve this bridge on that timer."; + const ctx = document.getElementById("ctx"); + setSteps(ctx && ctx.value.trim() ? 3 : 2); +} +async function createBridge() { + const ctx = document.getElementById("ctx"); + const status = document.getElementById("status"); + const button = document.getElementById("create"); + const text = ctx.value; + status.classList.remove("bad"); + if (!text.trim()) { + status.classList.add("bad"); + status.textContent = "Add the context the other side must read."; + ctx.focus(); + return; + } + if (!picked) { + status.classList.add("bad"); + status.textContent = "Choose a bridge time: 3 minutes, 1 hour, or 1 day."; + return; + } + button.disabled = true; + status.textContent = "Creating the bridge…"; + try { + const d = await api("POST", "/api/melds", { context: text, ttl: picked }); + code = d.code; + showCreated(d); + } catch (e) { + status.classList.add("bad"); + status.textContent = e.message; + button.disabled = false; + } +} +function showCreated(d) { + document.title = "meld — bridge is live"; + setSteps(3); + const when = d.expires_at ? new Date(d.expires_at).toLocaleString() : "the timer you chose"; + root.innerHTML = '

Share this capability URL

' + + '

Send this link to the other person or agent. The link is the bearer: whoever has it can read the live exchange and write the reply.

' + + '' + esc(d.url) + '' + + '

Bridge time: ' + esc(ttlLabel(d.ttl || picked)) + ' · dissolves ' + esc(when) + '

' + + '
' + + '
You do thisCopy the URL and send it to the other side.
' + + '
They do thisOpen the link, read your context, and write a reply.
' + + '
Then youStay on this page. It watches for the reply.
' + + '
Then it endsWhen the timer hits, the bridge dissolves.
' + + '
' + + trustBlock() + + '
' + + '
'; + document.getElementById("copy").onclick = copyLink; + document.getElementById("check").onclick = () => checkResult(false); + startWatch(); +} +function ttlLabel(ttl) { + return { "3m": "3 minutes", "1hr": "1 hour", "1d": "1 day" }[ttl] || ttl; +} +async function copyLink() { + const url = document.getElementById("share").textContent; + const status = document.getElementById("status"); + try { + await navigator.clipboard.writeText(url); + status.classList.remove("bad"); + status.textContent = "Link copied."; + } catch (e) { + status.classList.add("bad"); + status.textContent = "Select the link and copy it."; + } +} +function stopWatch() { + watching = false; + if (watchTimer) { + clearTimeout(watchTimer); + watchTimer = null; + } +} +function startWatch() { + watching = true; + checkResult(true); +} +function scheduleWatch(delay) { + if (!watching) return; + if (watchTimer) clearTimeout(watchTimer); + watchTimer = setTimeout(() => checkResult(true), delay); +} +async function checkResult(quiet) { + const status = document.getElementById("status"); + const result = document.getElementById("result"); + if (!status || !result) { + stopWatch(); + return; + } + if (!quiet) status.textContent = "Checking this bridge…"; + try { + const d = await api("GET", "/api/melds/" + code); + const waiting = !d.context_b; + result.innerHTML = '
Your context\n' + esc(d.context_a) + + '\n\nTheir reply\n' + esc(d.context_b || "No reply yet. The bridge is still live.") + "
"; + if (waiting) { + setSteps(4); + status.classList.remove("bad"); + status.textContent = "Watching for a reply."; + scheduleWatch(10000); + } else { + setSteps(5); + status.textContent = "Reply is on the link. The bridge still dissolves on the timer."; + stopWatch(); + } + } catch (e) { + const msg = e.message || ""; + const limited = /too many|429/i.test(msg); + const gone = /expired|410|not found|404|unavailable/i.test(msg); + if (limited && watching) { + status.classList.remove("bad"); + status.textContent = "Watching for a reply. Slowing down."; + scheduleWatch(45000); + return; + } + status.classList.add("bad"); + status.textContent = msg; + if (watching && !gone) scheduleWatch(20000); + else stopWatch(); + } +} +function meldPage() { + stopWatch(); + document.title = "meld — this bridge expires"; + code = decodeURIComponent(location.pathname.slice(3).split("/")[0]); + setReceiverJob(1); + root.innerHTML = "

Opening this bridge…

Loading the live exchange.

"; + api("GET", "/api/melds/" + code).then(d => { + if (d.resolved && d.context_b) resolved(d); + else receiver(d); + }).catch(e => { + root.innerHTML = "

This bridge is unavailable

" + esc(e.message) + "

It may have dissolved on its timer. This preview never included the exchange.

Open a new bridge

"; + }); +} +function remainingText(d) { + if (typeof d.seconds_remaining === "number") { + const s = d.seconds_remaining; + if (s < 90) return s + " seconds left"; + if (s < 7200) return Math.round(s / 60) + " minutes left"; + return Math.round(s / 3600) + " hours left"; + } + return d.expires_at ? ("dissolves " + new Date(d.expires_at).toLocaleString()) : "dissolves on its timer"; +} +function receiver(d) { + document.title = "meld — this bridge expires"; + setReceiverJob(2); + root.innerHTML = "

Reply on this bridge

" + + "

Read what they sent, write the reply, and you are done. " + esc(remainingText(d)) + ".

" + + "
" + esc(d.context_a) + "
" + + "" + + trustBlock(true) + + "
"; + document.getElementById("resolve").onclick = async () => { + const answer = document.getElementById("answer"); + const status = document.getElementById("status"); + const button = document.getElementById("resolve"); + if (!answer.value.trim()) { + status.classList.add("bad"); + status.textContent = "Write the reply first."; + answer.focus(); + return; + } + button.disabled = true; + status.classList.remove("bad"); + status.textContent = "Sending the reply…"; + try { + const out = await api("POST", "/api/melds/" + code + "/resolve", { context: answer.value }); + resolved(out); + } catch (e) { + status.classList.add("bad"); + status.textContent = e.message; + button.disabled = false; + } + }; +} +function resolved(d) { + document.title = "meld — this bridge expires"; + setReceiverJob(4); + root.innerHTML = "

Both sides are on the link

" + + "

The exchange stays readable by anyone with this link until the timer ends. Then it dissolves.

" + + "
Context\n" + esc(d.context_a) + "\n\nReply\n" + esc(d.context_b) + "
" + + trustBlock() + + "

Open another bridge

"; +} +document.addEventListener("visibilitychange", () => { + if (!watching) return; + if (document.hidden) { + if (watchTimer) { + clearTimeout(watchTimer); + watchTimer = null; + } + } else { + scheduleWatch(1000); + } +}); +const path = location.pathname; +if (path.startsWith("/m/")) meldPage(); +else landing(); + + + diff --git a/deploy/spa_papyrus.html b/deploy/spa_papyrus.html index 6a3e860..c4e0e5c 100644 --- a/deploy/spa_papyrus.html +++ b/deploy/spa_papyrus.html @@ -1,24 +1,393 @@ - -meld — context bridge to an agent + + +meld — timed bridge + -
+ + +
+
+
meld timed bridge
+ +
+
+ + +
+
+
\ No newline at end of file +const API = location.origin; +let code = null; +let picked = ""; +let watching = false; +let watchTimer = null; +// One read every 10s is 6/min, under the 60/min view limit. No new auth. +const esc = s => { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; }; +async function api(method, path, body, headers = {}) { + const r = await fetch(API + path, { + method, + headers: { "Content-Type": "application/json", "X-Meld-Client": "human", ...headers }, + body: body ? JSON.stringify(body) : undefined + }); + const d = await r.json().catch(() => ({ detail: r.statusText })); + if (!r.ok) throw Error(d.detail || String(r.status)); + return d; +} +function setSteps(active) { + document.getElementById("creator-rail").classList.remove("off"); + document.getElementById("receiver-rail").classList.add("off"); + for (let i = 1; i <= 4; i++) { + const el = document.getElementById("s" + i); + el.classList.toggle("on", i === active); + el.classList.toggle("done", i < active || active > 4); + } +} +function setReceiverJob(active) { + document.getElementById("creator-rail").classList.add("off"); + document.getElementById("receiver-rail").classList.remove("off"); + for (let i = 1; i <= 3; i++) { + const el = document.getElementById("r" + i); + el.classList.toggle("on", i === active); + el.classList.toggle("done", i < active || active > 3); + } +} +function trustBlock(forOpener) { + const last = forOpener + ? "The bridge dissolves when its timer runs out. Then the link is gone." + : "The bridge dissolves when the timer you choose runs out. Then the link is gone."; + return '
Read this before you put text on the bridge.
"; +} +function needsBlock() { + return '
' + + '
The handoffThe working context the other person or agent should read.
' + + '
A bridge timeOnly three: 3 minutes, 1 hour, or 1 day.
' + + '
The capability URLSharing the link is what lets the other side in.
' + + '
A reply on that linkThey open it, answer, and you read the reply here.
' + + "
"; +} +function landing() { + stopWatch(); + document.title = "meld — timed bridge"; + setSteps(1); + root.innerHTML = '

Open a timed bridge

' + + '

Hand one piece of working context to another person or agent. You get a capability URL. That URL is the shared bearer for the exchange, and it dies on the timer you pick.

' + + needsBlock() + + '' + + '' + + '

This text is what they see when they open the link. Leave out secrets.

' + + '' + + '
' + + timeBtn("3m", "3 minutes", "For a quick handoff. Dissolves in 3 minutes.") + + timeBtn("1hr", "1 hour", "For a working session. Dissolves in 1 hour.") + + timeBtn("1d", "1 day", "For a handoff that can wait. Dissolves in 1 day.") + + "
" + + '

Choose one. Nothing is selected until you do. There is no other duration.

' + + trustBlock() + + '
'; + const ctx = document.getElementById("ctx"); + const count = document.getElementById("count"); + ctx.addEventListener("input", () => { + count.textContent = ctx.value.length + " / 100000"; + setSteps(ctx.value.trim() && picked ? 3 : ctx.value.trim() ? 2 : 1); + }); + document.querySelectorAll(".time").forEach(btn => { + btn.addEventListener("click", () => selectTime(btn.dataset.ttl)); + }); + document.getElementById("create").addEventListener("click", createBridge); +} +function timeBtn(ttl, title, detail) { + return ''; +} +function selectTime(ttl) { + picked = ttl; + document.querySelectorAll(".time").forEach(btn => btn.setAttribute("aria-checked", btn.dataset.ttl === ttl ? "true" : "false")); + const labels = { "3m": "3 minutes", "1hr": "1 hour", "1d": "1 day" }; + document.getElementById("time-hint").textContent = "Selected: " + labels[ttl] + ". The server will dissolve this bridge on that timer."; + const ctx = document.getElementById("ctx"); + setSteps(ctx && ctx.value.trim() ? 3 : 2); +} +async function createBridge() { + const ctx = document.getElementById("ctx"); + const status = document.getElementById("status"); + const button = document.getElementById("create"); + const text = ctx.value; + status.classList.remove("bad"); + if (!text.trim()) { + status.classList.add("bad"); + status.textContent = "Add the context the other side must read."; + ctx.focus(); + return; + } + if (!picked) { + status.classList.add("bad"); + status.textContent = "Choose a bridge time: 3 minutes, 1 hour, or 1 day."; + return; + } + button.disabled = true; + status.textContent = "Creating the bridge…"; + try { + const d = await api("POST", "/api/melds", { context: text, ttl: picked }); + code = d.code; + showCreated(d); + } catch (e) { + status.classList.add("bad"); + status.textContent = e.message; + button.disabled = false; + } +} +function showCreated(d) { + document.title = "meld — bridge is live"; + setSteps(3); + const when = d.expires_at ? new Date(d.expires_at).toLocaleString() : "the timer you chose"; + root.innerHTML = '

Share this capability URL

' + + '

Send this link to the other person or agent. The link is the bearer: whoever has it can read the live exchange and write the reply.

' + + '' + esc(d.url) + '' + + '

Bridge time: ' + esc(ttlLabel(d.ttl || picked)) + ' · dissolves ' + esc(when) + '

' + + '
' + + '
You do thisCopy the URL and send it to the other side.
' + + '
They do thisOpen the link, read your context, and write a reply.
' + + '
Then youStay on this page. It watches for the reply.
' + + '
Then it endsWhen the timer hits, the bridge dissolves.
' + + '
' + + trustBlock() + + '
' + + '
'; + document.getElementById("copy").onclick = copyLink; + document.getElementById("check").onclick = () => checkResult(false); + startWatch(); +} +function ttlLabel(ttl) { + return { "3m": "3 minutes", "1hr": "1 hour", "1d": "1 day" }[ttl] || ttl; +} +async function copyLink() { + const url = document.getElementById("share").textContent; + const status = document.getElementById("status"); + try { + await navigator.clipboard.writeText(url); + status.classList.remove("bad"); + status.textContent = "Link copied."; + } catch (e) { + status.classList.add("bad"); + status.textContent = "Select the link and copy it."; + } +} +function stopWatch() { + watching = false; + if (watchTimer) { + clearTimeout(watchTimer); + watchTimer = null; + } +} +function startWatch() { + watching = true; + checkResult(true); +} +function scheduleWatch(delay) { + if (!watching) return; + if (watchTimer) clearTimeout(watchTimer); + watchTimer = setTimeout(() => checkResult(true), delay); +} +async function checkResult(quiet) { + const status = document.getElementById("status"); + const result = document.getElementById("result"); + if (!status || !result) { + stopWatch(); + return; + } + if (!quiet) status.textContent = "Checking this bridge…"; + try { + const d = await api("GET", "/api/melds/" + code); + const waiting = !d.context_b; + result.innerHTML = '
Your context\n' + esc(d.context_a) + + '\n\nTheir reply\n' + esc(d.context_b || "No reply yet. The bridge is still live.") + "
"; + if (waiting) { + setSteps(4); + status.classList.remove("bad"); + status.textContent = "Watching for a reply."; + scheduleWatch(10000); + } else { + setSteps(5); + status.textContent = "Reply is on the link. The bridge still dissolves on the timer."; + stopWatch(); + } + } catch (e) { + const msg = e.message || ""; + const limited = /too many|429/i.test(msg); + const gone = /expired|410|not found|404|unavailable/i.test(msg); + if (limited && watching) { + status.classList.remove("bad"); + status.textContent = "Watching for a reply. Slowing down."; + scheduleWatch(45000); + return; + } + status.classList.add("bad"); + status.textContent = msg; + if (watching && !gone) scheduleWatch(20000); + else stopWatch(); + } +} +function meldPage() { + stopWatch(); + document.title = "meld — this bridge expires"; + code = decodeURIComponent(location.pathname.slice(3).split("/")[0]); + setReceiverJob(1); + root.innerHTML = "

Opening this bridge…

Loading the live exchange.

"; + api("GET", "/api/melds/" + code).then(d => { + if (d.resolved && d.context_b) resolved(d); + else receiver(d); + }).catch(e => { + root.innerHTML = "

This bridge is unavailable

" + esc(e.message) + "

It may have dissolved on its timer. This preview never included the exchange.

Open a new bridge

"; + }); +} +function remainingText(d) { + if (typeof d.seconds_remaining === "number") { + const s = d.seconds_remaining; + if (s < 90) return s + " seconds left"; + if (s < 7200) return Math.round(s / 60) + " minutes left"; + return Math.round(s / 3600) + " hours left"; + } + return d.expires_at ? ("dissolves " + new Date(d.expires_at).toLocaleString()) : "dissolves on its timer"; +} +function receiver(d) { + document.title = "meld — this bridge expires"; + setReceiverJob(2); + root.innerHTML = "

Reply on this bridge

" + + "

Read what they sent, write the reply, and you are done. " + esc(remainingText(d)) + ".

" + + "
" + esc(d.context_a) + "
" + + "" + + trustBlock(true) + + "
"; + document.getElementById("resolve").onclick = async () => { + const answer = document.getElementById("answer"); + const status = document.getElementById("status"); + const button = document.getElementById("resolve"); + if (!answer.value.trim()) { + status.classList.add("bad"); + status.textContent = "Write the reply first."; + answer.focus(); + return; + } + button.disabled = true; + status.classList.remove("bad"); + status.textContent = "Sending the reply…"; + try { + const out = await api("POST", "/api/melds/" + code + "/resolve", { context: answer.value }); + resolved(out); + } catch (e) { + status.classList.add("bad"); + status.textContent = e.message; + button.disabled = false; + } + }; +} +function resolved(d) { + document.title = "meld — this bridge expires"; + setReceiverJob(4); + root.innerHTML = "

Both sides are on the link

" + + "

The exchange stays readable by anyone with this link until the timer ends. Then it dissolves.

" + + "
Context\n" + esc(d.context_a) + "\n\nReply\n" + esc(d.context_b) + "
" + + trustBlock() + + "

Open another bridge

"; +} +document.addEventListener("visibilitychange", () => { + if (!watching) return; + if (document.hidden) { + if (watchTimer) { + clearTimeout(watchTimer); + watchTimer = null; + } + } else { + scheduleWatch(1000); + } +}); +const path = location.pathname; +if (path.startsWith("/m/")) meldPage(); +else landing(); + + + diff --git a/deploy/worker.py b/deploy/worker.py index 1c52018..36935d0 100644 --- a/deploy/worker.py +++ b/deploy/worker.py @@ -1,10 +1,9 @@ """ -meld — ephemeral context bridge. Cloudflare Workers port (D1-backed). -Faithful port of the audited meld.py trust model: -- capability model: code admits, PIN authenticates answerer, rotating token reads -- per-IP sliding-window rate limits, body caps, string-validated contexts -- opaque relay: server stores/relays context verbatim, content-blind -- pay-per-meld ($3.33 one-time) + append-only ledger; no accounts, no emails at rest +meld — timed context bridge. Cloudflare Workers port (D1-backed). +- capability URL is the shared bearer for one exchange +- bridge time is exactly 3m, 1hr, or 1d; the server enforces that TTL +- pilot creates are free (no payment wall); per-minute abuse limits remain +- host-readable while live; anyone with the link can read it; dissolves on TTL State lives in D1 (survives restarts — an upgrade over the RAM dict). """ import hashlib @@ -56,6 +55,19 @@ def _require_context(context) -> str: return context +def _require_ttl(value) -> tuple: + """Accept only the three pilot bridge times. Missing or other values are rejected.""" + if not isinstance(value, str) or not value.strip(): + raise HTTPException( + 400, + "Bridge time is required. Choose 3m (3 minutes), 1hr (1 hour), or 1d (1 day).", + ) + key = value.strip().lower() + if key not in BRIDGE_TTLS: + raise HTTPException(400, "Bridge time must be one of: 3m, 1hr, 1d.") + return key, BRIDGE_TTLS[key] + + def _meld_meta(code, row, remaining): """Return capability metadata for a live meld.""" return { @@ -97,10 +109,9 @@ def _token() -> str: -# ── human vs agent create classifier (MVP: humans-free; agents-key-or-quota) ─ -# Skip FREE_LIMIT IP wall for humans. Keep per-minute abuse RL for everyone. -# Agents (explicit header, non-browser UA, or /v1 API-key path) still hit the -# free IP wall on POST /api/melds; Stripe per-meld unlock remains for that path. +# ── human vs agent create classifier (retained; pilot creates do not use it) ─ +# Pilot timed bridges are free for humans and agents. Per-minute abuse limits +# still apply. This classifier is not a payment gate. _AGENT_UA_MARKERS = ( "curl/", "python-requests", @@ -120,7 +131,9 @@ def _token() -> str: "crios/", "fxios/", ) -PRICING_HEADER = "humans-free; agents-key-or-quota" +PRICING_HEADER = "pilot-free" +# Pilot bridge lifetimes. Clients must send one of these keys. No default. +BRIDGE_TTLS = {"3m": 3 * 60, "1hr": 60 * 60, "1d": 24 * 60 * 60} def _is_human_client(request: Request) -> bool: @@ -210,11 +223,8 @@ async def _rate_limit(db_conn, kind: str, ip: str) -> bool: async def _check_meld_limit(db_conn, ip: str) -> bool: - """Free tier: FREE_LIMIT melds CREATED per window (spec MELD-FREELIMIT-002: - count creations, never live rows — melds are deleted on resolve/sweep, so - a live-row COUNT is '3 concurrent', not '3 created/window', and the wall - never fires for light users). No lease bypass exists: beyond the free - tier, a meld costs $3.33 one-time (webhook-unlocked).""" + """Legacy created-count helper. Pilot POST /api/melds does not call this. + Per-minute abuse limits still apply on create.""" window_key = str(int(time.time() // (FREE_EXPIRY_HOURS * 3600))) row = await db_conn.prepare( "INSERT INTO free_counts (ip, window_key, n) VALUES (?, ?, 1) " @@ -402,6 +412,7 @@ async def create_meld(request: Request): ip = _client_ip(request) body = await request.json() context = _require_context(body.get("context", "")) + ttl_key, ttl_seconds = _require_ttl(body.get("ttl")) if not await _rate_limit(conn, "create", ip): raise HTTPException(429, "Too many requests. Please slow down.", @@ -414,32 +425,14 @@ async def create_meld(request: Request): await _throttle_prune(conn) except Exception: pass # never block create on sweep failure - human = _is_human_client(request) - if not human and not await _check_meld_limit(conn, ip): - await _funnel(conn, "free_limit_hit") - # MELD-FREELIMIT-002: wall hits feed the throttle ladder, one offense - # per exhausted window (NAT guard: 2 hits in-window for rung 1). - await _register_wall_hit(conn, ip) - retry_after = str(max(1, int( - (datetime.datetime.fromtimestamp( - (int(time.time() // (FREE_EXPIRY_HOURS * 3600)) + 1) - * FREE_EXPIRY_HOURS * 3600, datetime.timezone.utc) - - datetime.datetime.now(datetime.timezone.utc)).total_seconds()))) - # Agents: payment protocols coming; key or wait; Stripe unlock still available. - raise HTTPException( - 429, - "Agent free limit reached (3/hour per IP). Agent payment protocols are coming; " - "for now get a key via POST /v1/keys or wait. " - 'Or unlock one meld ($3.33): POST /api/checkout {"meld_code":""} — ' - "https://meld.mergeinc.workers.dev/upgrade.md", - headers={"Retry-After": retry_after, "X-Meld-Pricing": PRICING_HEADER}, - ) + # Pilot: timed bridges are free for humans and agents. No hourly free wall + # and no payment unlock. Per-minute abuse limits above still apply. code = _code() token = _token() now = _now() expiry = (datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(hours=FREE_EXPIRY_HOURS)).isoformat() + + datetime.timedelta(seconds=ttl_seconds)).isoformat() email = body.get("email") pin = body.get("pin") # SB-3: do not persist creator_ip on the meld row. Rate limits use @@ -462,6 +455,7 @@ async def create_meld(request: Request): "owner_token": token, "context_a": context, "resolved": False, + "ttl": ttl_key, "expires_at": expiry, }, headers={"X-Meld-Pricing": PRICING_HEADER}, @@ -529,14 +523,14 @@ async def resolve_meld(code: str, request: Request): if (pk is None) != (sig is None): raise HTTPException(400, "Signature evidence requires both responder_pubkey and signature") - fast_expiry = (datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(minutes=10)).isoformat() # SB-3: do not persist resolver_ip on the meld row. + # The bridge keeps the TTL chosen at create. Resolve does not invent + # another lifetime. await conn.prepare( "UPDATE melds SET context_b = ?, resolved = 1, resolved_at = ?," - " responder_pubkey = ?, signature = ?," - " expires_at = MIN(expires_at, ?) WHERE code = ?").bind( - context, _now(), pk, sig, fast_expiry, code).run() + " responder_pubkey = ?, signature = ?" + " WHERE code = ?").bind( + context, _now(), pk, sig, code).run() await _funnel(conn, "resolved") return {"code": code, "context_a": row["context_a"], "context_b": context, "resolved": True} @@ -910,11 +904,12 @@ async def v1_create_meld(request: Request): body = await request.json() context = _require_context(body.get("context", "")) + ttl_key, ttl_seconds = _require_ttl(body.get("ttl")) code = _code() token = _token() now = _now() expiry = (datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(days=7)).isoformat() # agents get 7-day TTL + + datetime.timedelta(seconds=ttl_seconds)).isoformat() # SB-3: no IP (or key-hash stand-in) on the meld row. await conn.prepare( "INSERT INTO melds (code, context_a, context_b, resolved, owner_token," @@ -933,6 +928,8 @@ async def v1_create_meld(request: Request): "owner_token": token, "context_a": context, "resolved": False, + "ttl": ttl_key, + "expires_at": expiry, } @@ -968,12 +965,10 @@ async def v1_resolve_meld(code: str, request: Request): "context_b": row["context_b"], "resolved": True, "retry": True} raise HTTPException(409, "Already resolved with a different answer") - fast_expiry = (datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(minutes=10)).isoformat() await conn.prepare( - "UPDATE melds SET context_b = ?, resolved = 1, resolved_at = ?," - " expires_at = MIN(expires_at, ?) WHERE code = ?").bind( - context, _now(), fast_expiry, code).run() + "UPDATE melds SET context_b = ?, resolved = 1, resolved_at = ?" + " WHERE code = ?").bind( + context, _now(), code).run() await _meter_meld(conn, key_row["key_hash"], code) await _funnel(conn, "resolved") return {"code": code, "context_a": row["context_a"], "context_b": context, @@ -1022,6 +1017,35 @@ async def v1_usage(request: Request): # ── SPA ────────────────────────────────────────────────────────────────── PAGE = APP_HTML +# Homepage preview describes the product. A bare /m/{code} share must not put +# the exchange into og:title, og:description, or the initial HTML. +_PRODUCT_PREVIEW = ( + '' +) +_SHARE_PREVIEW = ( + '' + '' + '' + '' + '' + '' + '' + '' +) + + +def _render_page(*, share: bool) -> str: + preview = _SHARE_PREVIEW if share else _PRODUCT_PREVIEW + html = PAGE.replace("", preview, 1) + if share: + html = html.replace( + "meld — timed bridge", + "meld — this bridge expires", + 1, + ) + return html + @app.get("/m/{code}") async def serve_meld(request: Request, code: str): @@ -1045,7 +1069,7 @@ async def serve_meld(request: Request, code: str): "result": "GET /api/melds/{code}/result (X-Meld-Token)", "read": "GET /api/melds/{code} (the capability URL is the access)"} return _attach_bodies(out, row, has_token=has_token) - return HTMLResponse(PAGE) + return HTMLResponse(_render_page(share=True)) @@ -1156,9 +1180,11 @@ async def ai_plugin_json(): { "name": "meld_create", "description": ( - "Create a meld: an ephemeral context bridge. Returns a share link to give " - "the other party (human or agent) and an owner link to keep. The meld " - "expires (~1 hour free tier) after both parties exchange context." + "Create a timed bridge. Returns a capability URL (the shared bearer) for the " + "other party. You must set ttl to exactly one of 3m, 1hr, or 1d. There is no " + "default lifetime. Pilot bridges are free. The host can read the exchange " + "while it is live; anyone with the link can too. Not for secrets. Dissolves " + "when that TTL ends." ), "inputSchema": { "type": "object", @@ -1166,7 +1192,16 @@ async def ai_plugin_json(): "context": { "type": "string", "description": ( - "Your context: code, requirements, logs, a prompt, a question — anything." + "Working context the other party should read. Not for secrets, " + "credentials, or regulated data." + ), + }, + "ttl": { + "type": "string", + "enum": ["3m", "1hr", "1d"], + "description": ( + "Required bridge time. 3m = 3 minutes, 1hr = 1 hour, 1d = 1 day. " + "The server enforces this TTL. There is no default." ), }, "pin": { @@ -1174,7 +1209,7 @@ async def ai_plugin_json(): "description": "Optional PIN the other party must supply to answer.", }, }, - "required": ["context"], + "required": ["context", "ttl"], }, }, { @@ -1270,7 +1305,8 @@ async def _mcp_call_tool(name: str, args: dict, request: Request): args = args or {} try: if name == "meld_create": - body = {"context": args.get("context", "")} + # Pass ttl through unchanged. Do not invent a default. + body = {"context": args.get("context", ""), "ttl": args.get("ttl", "")} if args.get("pin"): body["pin"] = args["pin"] req = _asgi_json_request( @@ -1282,9 +1318,12 @@ async def _mcp_call_tool(name: str, args: dict, request: Request): "share_link": data.get("url"), "owner_link": data.get("owner_url"), "owner_token": data.get("owner_token"), + "ttl": data.get("ttl"), + "expires_at": data.get("expires_at"), "note": ( - "Send the share link to the other party. " - "Keep the owner link to read their answer." + "Send the share link. It is the capability URL: anyone holding it " + "can read the live exchange. The bridge dissolves at expires_at. " + "Not for secrets." ), } if name == "meld_resolve": @@ -1341,10 +1380,10 @@ async def _mcp_handle_message(msg: dict, request: Request): "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "meld", "version": "1.0.0"}, "instructions": ( - "meld: ephemeral two-party context bridge. Use meld_create to mint a " - "share URL, meld_resolve to answer, meld_read. A live capability URL returns " - "context to anyone holding the link. Humans free; agents " - "use key/quota. Base: https://meld.mergeinc.workers.dev" + "meld: timed context bridge. meld_create requires ttl: 3m, 1hr, or 1d " + "(no default). The returned URL is the capability. Host-readable while " + "live; anyone with the link can read it; not for secrets; dissolves on " + "that TTL. Pilot creates are free. Base: https://meld.mergeinc.workers.dev" ), }, } @@ -1449,19 +1488,46 @@ async def mcp_delete(): +# Old FastAPI mint-era pages (app/templates and /upgrade, /success, /cancel, /error). +# The worker does not render those templates. Unknown product paths still get the SPA. +# These paths 404 so the mint-era URLs are not a live surface. /upgrade.md and /pro stay. +_LEGACY_PAGE_PATHS = frozenset({ + "upgrade", "success", "cancel", "error", + "index.html", "upgrade.html", "success.html", "cancel.html", + "error.html", "meld.html", "base.html", +}) +_LEGACY_TEMPLATE_NAMES = frozenset({ + "index.html", "upgrade.html", "success.html", "cancel.html", + "error.html", "meld.html", "base.html", +}) + + +def _legacy_template_path(path: str) -> bool: + norm = (path or "").strip("/").lower() + if not norm or norm.endswith(".md"): + return False + if norm in _LEGACY_PAGE_PATHS: + return True + if norm.startswith("app/templates") or norm.startswith("templates/"): + return True + return norm.rsplit("/", 1)[-1] in _LEGACY_TEMPLATE_NAMES + + @app.get("/{path:path}") async def serve_page(path: str): - return HTMLResponse(PAGE) + if _legacy_template_path(path): + raise HTTPException(404, "Not found") + return HTMLResponse(_render_page(share=False)) @app.get("/") async def root(): - return HTMLResponse(PAGE) + return HTMLResponse(_render_page(share=False)) -AGENTS_ROOT_MD = '# AGENTS.md — working with meld\n\nmeld puts context on a capability URL with a TTL. The host is readable while live, and anyone with the link can read it. After TTL, the meld dissolves and the host serves 410. Not for secrets, credentials, or regulated data.\n\n## Quick start\n\n```bash\n# Create; share url. Keep owner_token only for the legacy /result read.\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"..."}\'\n# -> {code, url, owner_url, owner_token, expires_at}\n\n# Resolve from the link.\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' -d \'{"context":"..."}\'\n\n# Anyone holding the capability URL can read the live contexts.\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\n## Locked claims\n\n- Capability URL + TTL.\n- Host-readable while live.\n- Anyone with the link can read it.\n- Not for secrets/credentials/regulated.\n- Dissolves on TTL.\n- Mint-next means: create another meld URL and put it in the reply.\n\n## Agent-to-agent\n\nCreate, send the share URL, resolve once, then read the URL. For a follow-up, mint-next: create another meld URL and put it in the reply. MCP: https://meld.mergeinc.workers.dev/mcp\n\n## Limits and docs\n\nHumans are free in the browser. Agents have 3 creates/hour/IP or can use `POST /v1/keys`. Per-minute limits apply to everyone. Errors: 400, 403 PIN, 404, 409, 410, 429.\n\nMachine-readable docs: /llms.txt · /agents.md · /recipes.md · /openapi.json · /trust.md\n' +AGENTS_ROOT_MD = '# AGENTS.md — working with meld\n\nmeld puts context on a capability URL with a TTL. The host is readable while live, and anyone with the link can read it. After TTL, the meld dissolves and the host serves 410. Not for secrets, credentials, or regulated data.\n\n## Quick start\n\n```bash\n# Create; share url. Keep owner_token only for the legacy /result read.\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"...","ttl":"1hr"}\'\n# -> {code, url, owner_url, owner_token, expires_at}\n\n# Resolve from the link.\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' -d \'{"context":"..."}\'\n\n# Anyone holding the capability URL can read the live contexts.\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\n## Locked claims\n\n- Capability URL + TTL.\n- Host-readable while live.\n- Anyone with the link can read it.\n- Not for secrets/credentials/regulated.\n- Dissolves on TTL.\n- Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default.\n\n## Agent-to-agent\n\nCreate, send the share URL, resolve once, then read the URL. Pass `ttl` as `3m`, `1hr`, or `1d`. There is no default. MCP: https://meld.mergeinc.workers.dev/mcp\n\n## Limits and docs\n\nPilot bridges are free. Create requires `ttl`: `3m`, `1hr`, or `1d`. Per-minute limits apply to everyone. Errors: 400, 403 PIN, 404, 409, 410, 429.\n\nMachine-readable docs: /llms.txt · /agents.md · /recipes.md · /openapi.json · /trust.md\n' -LLMS_TXT = '# meld\n> Capability URL + TTL for a one-time context handoff.\n\nBase URL: https://meld.mergeinc.workers.dev\n\nLocked claims: host-readable while live; anyone with the link can read it; not for secrets/credentials/regulated; dissolves on TTL. Mint-next means: create another meld URL and put it in the reply.\n\n## Flow\n\n1. `POST /api/melds` with `{"context":"..."}` -> `code`, `url`, `owner_token`, `expires_at`.\n2. Share `url` with the other party. The URL is the capability.\n3. `POST /api/melds/{code}/resolve` with `{"context":"..."}` to answer.\n4. `GET /api/melds/{code}` -> the live context for anyone holding the link.\n5. For a follow-up, mint-next: create another meld URL and put it in the reply.\n\nTTL: unresolved 1 hour; after resolve about 10 minutes. Then 410 Gone. The meld dissolves on TTL.\nContent limit: 100,000 characters. Not for secrets, credentials, or regulated data.\n\n## Agent quick start\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"..."}\'\n# share .url; resolve with the returned code\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' -d \'{"context":"..."}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\n## Docs and integrations\n\n- Agent docs: https://meld.mergeinc.workers.dev/agents.md\n- Recipes: https://meld.mergeinc.workers.dev/recipes.md\n- Trust: https://meld.mergeinc.workers.dev/trust.md\n- OpenAPI: https://meld.mergeinc.workers.dev/openapi.json\n- MCP remote: https://meld.mergeinc.workers.dev/mcp\n- MCP manifest: https://meld.mergeinc.workers.dev/.well-known/mcp.json\n- Agent card: https://meld.mergeinc.workers.dev/.well-known/agent.json\n\nHumans are free in the browser. Agents get 3 creates/hour/IP or `POST /v1/keys`; per-minute limits apply to everyone.\n' +LLMS_TXT = '# meld\n> Capability URL + TTL for a one-time context handoff.\n\nBase URL: https://meld.mergeinc.workers.dev\n\nLocked claims: host-readable while live; anyone with the link can read it; not for secrets/credentials/regulated; dissolves on TTL. Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default.\n\n## Flow\n\n1. `POST /api/melds` with `{"context":"...","ttl":"1hr"}` -> `code`, `url`, `owner_token`, `expires_at`.\n2. Share `url` with the other party. The URL is the capability.\n3. `POST /api/melds/{code}/resolve` with `{"context":"..."}` to answer.\n4. `GET /api/melds/{code}` -> the live context for anyone holding the link.\n\nTTL: required on create, one of 3m (3 minutes), 1hr (1 hour), or 1d (1 day). The server enforces it. Then 410 Gone.\nContent limit: 100,000 characters. Not for secrets, credentials, or regulated data.\n\n## Agent quick start\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"...","ttl":"1hr"}\'\n# share .url; resolve with the returned code\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' -d \'{"context":"..."}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\n## Docs and integrations\n\n- Agent docs: https://meld.mergeinc.workers.dev/agents.md\n- Recipes: https://meld.mergeinc.workers.dev/recipes.md\n- Trust: https://meld.mergeinc.workers.dev/trust.md\n- OpenAPI: https://meld.mergeinc.workers.dev/openapi.json\n- MCP remote: https://meld.mergeinc.workers.dev/mcp\n- MCP manifest: https://meld.mergeinc.workers.dev/.well-known/mcp.json\n- Agent card: https://meld.mergeinc.workers.dev/.well-known/agent.json\n\nPilot bridges are free. Create requires `ttl`: `3m`, `1hr`, or `1d`. Per-minute limits apply to everyone.\n' ROBOTS_TXT = """User-agent: GPTBot Allow: / @@ -1484,14 +1550,22 @@ async def root(): """ -AGENTS_MD = '# meld — agent API\n\nmeld is a capability URL + TTL for one context exchange. Host-readable while live; anyone with the link can read it. Not for secrets/credentials/regulated. The meld dissolves on TTL.\n\n## Create -> resolve -> read\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"What architecture fits 10M users?"}\'\n# share the returned .url and note .code\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' \\\n -d \'{"context":"Event-driven services plus a queue."}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\nThe capability URL is the access. `owner_token` and `/result` remain as a legacy owner-read path. Resolve is one answer; identical retries are idempotent and a conflicting answer returns 409.\n\n## Mint-next\n\nIf more context is needed, mint-next means: create another meld URL and put it in the reply.\n\n```bash\nnext=$(curl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"Follow-up: ..."}\')\n# Put next.url in the reply.\n```\n\nMCP remote: https://meld.mergeinc.workers.dev/mcp · Recipes: /recipes.md · OpenAPI: /openapi.json\n' +AGENTS_MD = '# meld — agent API\n\nmeld is a capability URL + TTL for one context exchange. Host-readable while live; anyone with the link can read it. Not for secrets/credentials/regulated. The meld dissolves on TTL.\n\n## Create -> resolve -> read\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds \\\n -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' \\\n -d \'{"context":"What architecture fits 10M users?","ttl":"1hr"}\'\n# share the returned .url and note .code\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve \\\n -H \'content-type: application/json\' \\\n -d \'{"context":"Event-driven services plus a queue."}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\nThe capability URL is the access. `owner_token` and `/result` remain as a legacy owner-read path. Resolve is one answer; identical retries are idempotent and a conflicting answer returns 409.\n\n## Bridge time\n\n`ttl` is required on create and must be `3m`, `1hr`, or `1d`. The server enforces that lifetime. There is no default. Pilot creates are free.\n\nMCP remote: https://meld.mergeinc.workers.dev/mcp · Recipes: /recipes.md · OpenAPI: /openapi.json\n' -TRUST_MD = '# meld — trust model\n\n- Capability URL + TTL: the URL grants access while the meld is live.\n- Host-readable while live.\n- Anyone with the link can read it.\n- Not for secrets/credentials/regulated.\n- Dissolves on TTL; unresolved melds last up to 1 hour and resolved melds about 10 minutes.\n- Mint-next means: create another meld URL and put it in the reply.\n\nThe host stores ordinary context for the live TTL and deletes the meld after expiry. There are no accounts or long-term content archives. Rate-limit identity is IP-based. Use meld for ordinary, disposable handoffs only.\n' +TRUST_MD = '# meld — trust model\n\n- Capability URL + TTL: the URL grants access while the meld is live.\n- Host-readable while live.\n- Anyone with the link can read it.\n- Not for secrets/credentials/regulated.\n- Dissolves on the TTL chosen at create: 3 minutes (`3m`), 1 hour (`1hr`), or 1 day (`1d`).\n- Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default.\n\nThe host stores ordinary context for the live TTL and deletes the meld after expiry. There are no accounts or long-term content archives. Rate-limit identity is IP-based. Use meld for ordinary, disposable handoffs only.\n' -UPGRADE_MD = '# meld — pricing\n\nHumans: free in the browser (`X-Meld-Client: human` or browser UA). No IP free-wall.\nAgents on POST /api/melds: 3 melds per IP per hour; then mint a key or wait.\nAgent payment protocols are coming. Header: `X-Meld-Pricing: humans-free; agents-key-or-quota`.\n\nAgent API keys — POST /v1/keys -> mk_... key, 10,000 melds/key, 7-day TTL,\nusage metered at /v1/usage. Free, rate-limited (5/min), no account.\n\nOptional agent wall unlock — $3.33 one-time (no subscription, nothing recurring):\n- Unlocks one specific meld beyond the agent free tier\n- POST /api/checkout {"meld_code": ""} in a browser (Stripe Checkout)\n\nThere are no subscriptions. Every payment is one-time, per meld.\n\nRate limits (all tiers): 20 creates/min, 10 resolves/min, 60 views/min per IP.\n1-hour TTL unresolved; ~10min after resolve. No account.\n' +UPGRADE_MD = """# meld — pilot +Timed bridges are free for pilot users. No payment is required to create one. -RECIPES_MD = '# meld recipes\n\nEvery recipe uses the same locked bar: capability URL + TTL; host-readable while live; anyone with the link can read it; not for secrets/credentials/regulated; dissolves on TTL.\n\n## 1. FDE institutional-knowledge gather\n\nCreate with the question and repo paths, send the URL to the human/on-call, then read the same URL after they resolve.\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' -d \'{"context":"Question + repo paths + known constraints"}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve -H \'content-type: application/json\' -d \'{"context":"The institutional answer"}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\n## 2. Provider-switch context handoff\n\nPut goals, constraints, files, and next step in one meld URL. The new provider opens the URL, adds its answer, and the old provider reads the result.\n\n## 3. Provider-switch request-meld\n\nThe new provider creates a URL containing the request. The old provider opens it, adds its working context, and the new provider reads the resolved URL.\n\n## 4. Short create -> resolve -> mint-next recipe\n\nPaste the handoff into `POST /api/melds`, send the returned `.url`, and have the recipient `POST /api/melds/{code}/resolve`. Read `GET /api/melds/{code}`. If either agent needs more, mint-next means: create another meld URL and put it in the reply. MCP links are OK: https://meld.mergeinc.workers.dev/mcp\n\n```text\ncreate -> share URL -> resolve -> read URL -> if needed create next URL -> put next URL in reply\n```\n\nHumans are free in the browser. Agents have 3 creates/hour/IP or can use `POST /v1/keys`; per-minute limits apply to everyone.\n' +Choose a bridge time on create: `3m` (3 minutes), `1hr` (1 hour), or `1d` (1 day). The server enforces that TTL. There is no other duration and no default. + +The link is the capability. The host can read the exchange while it is live. Anyone with the link can read it. Not for secrets, credentials, or regulated data. The bridge dissolves when the timer ends. + +Abuse limits still apply: 20 creates/min, 10 resolves/min, 60 views/min per IP. +""" + +RECIPES_MD = '# meld recipes\n\nEvery recipe uses the same locked bar: capability URL + TTL; host-readable while live; anyone with the link can read it; not for secrets/credentials/regulated; dissolves on TTL.\n\n## 1. FDE institutional-knowledge gather\n\nCreate with the question and repo paths, send the URL to the human/on-call, then read the same URL after they resolve.\n\n```bash\ncurl -s https://meld.mergeinc.workers.dev/api/melds -H \'content-type: application/json\' -H \'X-Meld-Client: agent\' -d \'{"context":"Question + repo paths + known constraints","ttl":"1hr"}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve -H \'content-type: application/json\' -d \'{"context":"The institutional answer"}\'\ncurl -s https://meld.mergeinc.workers.dev/api/melds/{code}\n```\n\n## 2. Provider-switch context handoff\n\nPut goals, constraints, files, and next step in one meld URL. The new provider opens the URL, adds its answer, and the old provider reads the result.\n\n## 3. Provider-switch request-meld\n\nThe new provider creates a URL containing the request. The old provider opens it, adds its working context, and the new provider reads the resolved URL.\n\n## 4. Create, share, resolve, read\n\n`POST /api/melds` requires `context` and `ttl` (`3m`, `1hr`, or `1d`). There is no default. Send the returned `.url`. The recipient resolves on that URL. Read `GET /api/melds/{code}` while the bridge is live. MCP: https://meld.mergeinc.workers.dev/mcp\n\n```text\nchoose ttl -> create -> share URL -> resolve -> read URL\n```\n\nPilot bridges are free. Create requires `ttl`: `3m`, `1hr`, or `1d`. Per-minute limits apply to everyone.\n' MCP_SERVER_CARD = { 'serverInfo': {'name': 'meld', 'version': '1.0.0'}, @@ -1508,14 +1582,18 @@ async def root(): { 'name': 'meld_create', 'description': ( - 'Create an ephemeral context bridge. Returns a share link for the ' - 'counterpart and an owner token to read the answer. Content max 100K ' - 'chars, TTL 1h unresolved / ~10min post-exchange, then deleted.' + 'Create a timed bridge. ttl is required: 3m, 1hr, or 1d. There is no ' + 'default. Returns the capability URL. Pilot creates are free. ' + 'Dissolves on that TTL.' ), 'inputSchema': { 'type': 'object', - 'properties': {'context': {'type': 'string'}, 'pin': {'type': 'string'}}, - 'required': ['context'], + 'properties': { + 'context': {'type': 'string'}, + 'ttl': {'type': 'string', 'enum': ['3m', '1hr', '1d']}, + 'pin': {'type': 'string'}, + }, + 'required': ['context', 'ttl'], }, }, { @@ -1559,7 +1637,7 @@ async def root(): 'version': '1.0.0', 'protocolVersion': '0.2.9', 'protocols': ['http', 'a2a', 'mcp'], - 'pricing': {'unit': 'request', 'amount': 0, 'currency': 'free', 'note': 'Humans free in browser; agents key-or-quota (3/IP/hour then POST /v1/keys)'}, + 'pricing': {'unit': 'request', 'amount': 0, 'currency': 'free', 'note': 'Pilot bridges are free. ttl required: 3m, 1hr, or 1d'}, 'availability': {'now': True, 'window_hours': 168, 'sla': 'best-effort'}, 'contact': { 'http': 'https://meld.mergeinc.workers.dev/api/melds', @@ -1572,16 +1650,16 @@ async def root(): 'provider': {'organization': 'meld', 'url': 'https://meld.mergeinc.workers.dev'}, 'documentationUrl': 'https://meld.mergeinc.workers.dev/agents.md', 'skills': [ - {'id': 'meld-create', 'name': 'meld_create', 'description': 'Create an ephemeral context link. Returns a share URL (for the counterpart) and an owner token (to read the answer). Context max 100K chars. TTL 1h unresolved, ~10min post-exchange, then deleted.', 'tags': ['context-sharing', 'ephemeral', 'handoff', 'rendezvous', 'agent-to-agent'], 'examples': ['Create a meld with context: What architecture fits 10M users?']}, + {'id': 'meld-create', 'name': 'meld_create', 'description': 'Create a timed bridge. ttl is required: 3m, 1hr, or 1d. No default. Returns the capability URL. Pilot creates are free. Dissolves on that TTL.', 'tags': ['context-sharing', 'ephemeral', 'handoff', 'rendezvous', 'agent-to-agent'], 'examples': ['Create a meld with context and ttl 1hr.']}, {'id': 'meld-resolve', 'name': 'meld_resolve', 'description': "Answer a meld link you were given. Submit your context and receive the original party's context. Idempotent for identical answers; conflicting answers rejected with 409.", 'tags': ['context-sharing', 'answer', 'handoff'], 'examples': ['Resolve meld code abc123 with context: Event-driven services plus a queue.']}, {'id': 'meld-read', 'name': 'meld_read', 'description': "Read the counterpart's answer using the owner token. Token rotates on every read; persist the new token.", 'tags': ['context-sharing', 'read', 'result'], 'examples': ['Read result for meld code abc123 with the owner token from create.']}, ], } -SKILL_MD = '---\nname: meld\ndescription: Capability URL + TTL for a one-time context handoff. Host-readable while live; anyone with the link can read it; dissolves on TTL.\n---\n\n# meld\n\nUse meld for one-time context exchange. It is not for secrets/credentials/regulated data.\n\n1. Create with `POST /api/melds` and share the returned URL.\n2. Resolve with `POST /api/melds/{code}/resolve`.\n3. Read the live context with `GET /api/melds/{code}`.\n4. Mint-next means: create another meld URL and put it in the reply.\n\nMCP: https://meld.mergeinc.workers.dev/mcp\nRecipes: https://meld.mergeinc.workers.dev/recipes.md\n' +SKILL_MD = '---\nname: meld\ndescription: Capability URL + TTL for a one-time context handoff. Host-readable while live; anyone with the link can read it; dissolves on TTL.\n---\n\n# meld\n\nUse meld for one-time context exchange. It is not for secrets/credentials/regulated data.\n\n1. Create with `POST /api/melds` and share the returned URL.\n2. Resolve with `POST /api/melds/{code}/resolve`.\n3. Read the live context with `GET /api/melds/{code}`.\n4. Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default.\n\nMCP: https://meld.mergeinc.workers.dev/mcp\nRecipes: https://meld.mergeinc.workers.dev/recipes.md\n' -SKILLS_INDEX = {'$schema': 'https://schemas.agentskills.io/discovery/0.2.0/schema.json', 'skills': [{'name': 'meld', 'description': 'Capability URL + TTL for a one-time context handoff. Host-readable while live; anyone with the link can read it; dissolves on TTL.', 'type': 'skill-md', 'url': 'https://meld.mergeinc.workers.dev/skill.md', 'digest': 'sha256:21c9bd965d01e37c5c7e77479a55e019fc7caad32b517d73011b366621bdd978'}]} +SKILLS_INDEX = {'$schema': 'https://schemas.agentskills.io/discovery/0.2.0/schema.json', 'skills': [{'name': 'meld', 'description': 'Capability URL + TTL for a one-time context handoff. Host-readable while live; anyone with the link can read it; dissolves on TTL.', 'type': 'skill-md', 'url': 'https://meld.mergeinc.workers.dev/skill.md', 'digest': 'sha256:3229ba12e547e9503f98c03cba1e0829396aad521e8c8675a5f27bbb739352ec'}]} MCP_MANIFEST = { @@ -1623,7 +1701,7 @@ async def root(): "name_for_human": "meld", "name_for_model": "meld", "description_for_human": "Capability URL + TTL for a one-time context handoff. Host-readable while live; anyone with the link can read it; dissolves on TTL.", - "description_for_model": "Create a capability URL + TTL for a one-time context handoff. POST /api/melds with {context} to get a share URL; counterpart POST /api/melds/{code}/resolve; GET /api/melds/{code} returns the live context to anyone with the link. Host-readable while live, not for secrets/credentials/regulated, dissolves on TTL. Humans free in browser; agents: 3/IP/hour then POST /v1/keys.", + "description_for_model": "Create a capability URL + TTL for a one-time context handoff. POST /api/melds with {context, ttl} where ttl is 3m, 1hr, or 1d (required, no default). The URL is the capability. Counterpart POST /api/melds/{code}/resolve; GET /api/melds/{code} returns the live context to anyone with the link. Host-readable while live, not for secrets/credentials/regulated, dissolves on that TTL. Pilot creates are free.", "auth": {"type": "none"}, "api": { "type": "openapi", @@ -1643,8 +1721,7 @@ async def root():
  • Host-readable while live.
  • Anyone with the link can read it.
  • Not for secrets/credentials/regulated.
  • -
  • Dissolves on TTL. Unresolved melds last up to one hour; resolved melds last about ten minutes.
  • -
  • Mint-next. Create another meld URL and put it in the reply.
  • +
  • Dissolves on TTL. Choose 3 minutes, 1 hour, or 1 day at create. The server enforces that timer.
  • meld is an ephemeral context handoff, not a vault. The host stores ordinary context only for the live TTL, then deletes the meld. There are no accounts or long-term content archives.

    Agent docs · Agents · Create a meld

    diff --git a/mcp/README.md b/mcp/README.md index fd6cdfa..d6770e7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -12,8 +12,7 @@ https://meld.mergeinc.workers.dev/mcp Transport: [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) in JSON response mode (stateless; no SSE session required). Auth: none. -Humans free in browser; agent tool calls hit the same create/resolve/read APIs -(agent key/quota applies on create). +Pilot bridges are free. `meld_create` requires `ttl`: `3m`, `1hr`, or `1d`. There is no default. Smoke: @@ -55,7 +54,7 @@ Or: `"command": "npx", "args": ["meld-mcp"]` when published. | Tool | Purpose | |---|---| -| `meld_create` | Create a meld with your context → returns share + owner links | +| `meld_create` | Create a bridge. Requires `context` and `ttl` (`3m`, `1hr`, or `1d`) → capability URL | | `meld_resolve` | Answer a meld you received → returns the other party's context | | `meld_read` | Owner: read the resolved result (token rotates each read) | diff --git a/mcp/meld-mcp.mjs b/mcp/meld-mcp.mjs index 8a15518..f1d6188 100644 --- a/mcp/meld-mcp.mjs +++ b/mcp/meld-mcp.mjs @@ -42,14 +42,19 @@ const TOOLS = [ { name: "meld_create", description: - "Create a meld: an ephemeral context bridge. Returns a share link to give the other party (human or agent) and an owner link to keep. The meld expires (~1 hour free tier) after both parties exchange context.", + "Create a timed bridge. Returns a capability URL for the other party. ttl is required: 3m, 1hr, or 1d. There is no default. Pilot bridges are free. The host can read the exchange while it is live; anyone with the link can too. Not for secrets. Dissolves when that TTL ends.", inputSchema: { type: "object", properties: { - context: { type: "string", description: "Your context: code, requirements, logs, a prompt, a question — anything." }, + context: { type: "string", description: "Working context the other party should read. Not for secrets, credentials, or regulated data." }, + ttl: { + type: "string", + enum: ["3m", "1hr", "1d"], + description: "Required bridge time. 3m = 3 minutes, 1hr = 1 hour, 1d = 1 day. The server enforces this TTL. There is no default.", + }, pin: { type: "string", description: "Optional PIN the other party must supply to answer." }, }, - required: ["context"], + required: ["context", "ttl"], }, }, { @@ -83,14 +88,20 @@ const TOOLS = [ async function callTool(name, args) { if (name === "meld_create") { - const body = { context: args.context }; + const allowed = new Set(["3m", "1hr", "1d"]); + if (!allowed.has(args.ttl)) { + throw new Error("Bridge time is required. Choose 3m, 1hr, or 1d. There is no default."); + } + const body = { context: args.context, ttl: args.ttl }; if (args.pin) body.pin = args.pin; const d = await api("POST", "/api/melds", body); return { code: d.code, share_link: d.url, owner_link: d.owner_url, - note: "Send the share link to the other party. Keep the owner link to read their answer.", + ttl: d.ttl, + expires_at: d.expires_at, + note: "Send the share link. It is the capability URL. The bridge dissolves at expires_at. Not for secrets.", }; } if (name === "meld_resolve") { diff --git a/npm-client/README.md b/npm-client/README.md index 9e6a15a..b83de22 100644 --- a/npm-client/README.md +++ b/npm-client/README.md @@ -16,7 +16,7 @@ npm install meld-bridge import { create, view, resolve, result } from "meld-bridge"; // Agent A: create a meld -const meld = await create("Auth flow uses OAuth2+PKCE, JWT tokens, refresh rotation"); +const meld = await create("Auth flow uses OAuth2+PKCE, JWT tokens, refresh rotation", { ttl: "1hr" }); console.log(`Share this link: ${meld.url}`); console.log(`Save this token: ${meld.owner_token}`); @@ -34,7 +34,7 @@ console.log(`Answer: ${res.context_b}`); ## CLI ```bash -npx meld-bridge send "Auth flow uses OAuth2+PKCE" +npx meld-bridge send --ttl 1hr "Auth flow uses OAuth2+PKCE" npx meld-bridge resolve "Add rate limiting to token refresh" npx meld-bridge read ``` @@ -48,7 +48,7 @@ meld makes dissolution the default — no cleanup, no stale data, no trust neede | Function | Description | |---|---| -| `create(context, opts?)` | Create a meld, returns `{code, url, owner_token}` | +| `create(context, { ttl })` | Create a bridge. `ttl` is required: `3m`, `1hr`, or `1d` | | `view(code)` | View context_a (and context_b if resolved) | | `resolve(code, context)` | Answer a meld | | `result(code, ownerToken)` | Read the merged result | diff --git a/npm-client/cli.js b/npm-client/cli.js index 05dbdc7..ccebcaf 100755 --- a/npm-client/cli.js +++ b/npm-client/cli.js @@ -22,9 +22,14 @@ async function api(method, path, body, extraHeaders = {}) { async function main() { try { if (cmd === "send") { - const context = args.join(" "); - if (!context) { console.error("Usage: meld send "); process.exit(1); } - const m = await api("POST", "/api/melds", { context }); + const ttlFlag = args.indexOf("--ttl"); + const ttl = ttlFlag >= 0 ? args[ttlFlag + 1] : ""; + const context = args.filter((a, i) => a !== "--ttl" && i !== ttlFlag + 1).join(" "); + if (!["3m", "1hr", "1d"].includes(ttl) || !context) { + console.error("Usage: meld send --ttl <3m|1hr|1d> "); + process.exit(1); + } + const m = await api("POST", "/api/melds", { context, ttl }); console.log(`\n meld created: ${m.code}\n`); console.log(` share link: ${m.url}`); if (m.owner_url) { @@ -71,7 +76,7 @@ async function main() { meld — ephemeral context bridge Usage: - meld send Create a meld + meld send --ttl <3m|1hr|1d> Create a timed bridge meld resolve Resolve a meld meld read Read the result meld view View a meld diff --git a/npm-client/index.d.ts b/npm-client/index.d.ts index a2b2064..644e8a1 100644 --- a/npm-client/index.d.ts +++ b/npm-client/index.d.ts @@ -32,10 +32,12 @@ export interface MeldOptions { baseUrl?: string; /** API key for agent tier (Bearer auth) */ apiKey?: string; + /** Required on create: 3m, 1hr, or 1d. There is no default. */ + ttl?: "3m" | "1hr" | "1d"; } -/** Create a new meld with your context */ -export declare function create(context: string, opts?: MeldOptions): Promise; +/** Create a timed bridge. opts.ttl is required: 3m, 1hr, or 1d. */ +export declare function create(context: string, opts: MeldOptions & { ttl: "3m" | "1hr" | "1d" }): Promise; /** View a meld by code (returns context_a, and context_b if resolved) */ export declare function view(code: string, opts?: MeldOptions): Promise; diff --git a/npm-client/index.js b/npm-client/index.js index c96ff2f..a011fca 100644 --- a/npm-client/index.js +++ b/npm-client/index.js @@ -4,13 +4,18 @@ function getBase(opts) { return (opts && opts.baseUrl) || process.env.MELD_URL || DEFAULT_BASE; } +const BRIDGE_TTLS = new Set(["3m", "1hr", "1d"]); + export async function create(context, opts = {}) { + if (!BRIDGE_TTLS.has(opts.ttl)) { + throw new Error("ttl is required: 3m, 1hr, or 1d. There is no default."); + } const base = getBase(opts); const headers = { "Content-Type": "application/json" }; if (opts.apiKey) headers["Authorization"] = `Bearer ${opts.apiKey}`; const res = await fetch(`${base}/api/melds`, { method: "POST", headers, - body: JSON.stringify({ context }) + body: JSON.stringify({ context, ttl: opts.ttl }) }); if (!res.ok) { const err = await res.json().catch(() => ({})); diff --git a/npm-client/test.js b/npm-client/test.js index d9e8811..2ab7e41 100644 --- a/npm-client/test.js +++ b/npm-client/test.js @@ -4,7 +4,7 @@ import { create, view, resolve, result } from "./index.js"; const BASE = process.env.MELD_URL || "http://127.0.0.1:8080"; async function main() { - const m = await create("SDK test: what is 2+2?"); + const m = await create("SDK test: what is 2+2?", { ttl: "3m", baseUrl: BASE }); console.log("create OK:", m.code); const v = await view(m.code); console.log("view OK:", v.context_a === "SDK test: what is 2+2?"); diff --git a/npm-client/test.mjs b/npm-client/test.mjs index d2bd83f..792ac5a 100644 --- a/npm-client/test.mjs +++ b/npm-client/test.mjs @@ -7,7 +7,7 @@ async function main() { console.log("Testing meld SDK against", BASE); // 1. Create - const m = await create("SDK test: the auth flow uses OAuth2+PKCE"); + const m = await create("SDK test: the auth flow uses OAuth2+PKCE", { ttl: "1hr", baseUrl: BASE }); assert(m.code && m.code.length === 12, "code should be 12 chars"); assert(m.owner_token && m.owner_token.length === 64, "owner_token should be 64 chars"); assert(m.owner_url && m.owner_url.includes("#t="), "owner_url should have fragment"); diff --git a/recipes/RECIPES.md b/recipes/RECIPES.md index ca64ce1..eacd83c 100644 --- a/recipes/RECIPES.md +++ b/recipes/RECIPES.md @@ -1,23 +1,31 @@ # meld recipes -Every recipe uses the locked bar: capability URL + TTL; host-readable while live; anyone with the link can read it; not for secrets/credentials/regulated; dissolves on TTL. +Every recipe uses the same locked bar: capability URL + TTL; host-readable while live; anyone with the link can read it; not for secrets/credentials/regulated; dissolves on TTL. -## FDE institutional-knowledge gather +## 1. FDE institutional-knowledge gather Create with the question and repo paths, send the URL to the human/on-call, then read the same URL after they resolve. -## Provider-switch context handoff +```bash +curl -s https://meld.mergeinc.workers.dev/api/melds -H 'content-type: application/json' -H 'X-Meld-Client: agent' -d '{"context":"Question + repo paths + known constraints","ttl":"1hr"}' +curl -s https://meld.mergeinc.workers.dev/api/melds/{code}/resolve -H 'content-type: application/json' -d '{"context":"The institutional answer"}' +curl -s https://meld.mergeinc.workers.dev/api/melds/{code} +``` + +## 2. Provider-switch context handoff -Put goals, constraints, files, and next step in one meld URL. The new provider opens it, adds its answer, and the old provider reads the result. +Put goals, constraints, files, and next step in one meld URL. The new provider opens the URL, adds its answer, and the old provider reads the result. -## Provider-switch request-meld +## 3. Provider-switch request-meld The new provider creates a URL containing the request. The old provider opens it, adds its working context, and the new provider reads the resolved URL. -## Short create -> resolve -> mint-next +## 4. Create, share, resolve, read -Paste the handoff into `POST /api/melds`, send the returned `.url`, and have the recipient `POST /api/melds/{code}/resolve`. Read `GET /api/melds/{code}`. If either agent needs more, mint-next means: create another meld URL and put it in the reply. MCP links are OK: https://meld.mergeinc.workers.dev/mcp +`POST /api/melds` requires `context` and `ttl` (`3m`, `1hr`, or `1d`). There is no default. Send the returned `.url`. The recipient resolves on that URL. Read `GET /api/melds/{code}` while the bridge is live. MCP: https://meld.mergeinc.workers.dev/mcp ```text -create -> share URL -> resolve -> read URL -> if needed create next URL -> put next URL in reply +choose ttl -> create -> share URL -> resolve -> read URL ``` + +Pilot bridges are free. Create requires `ttl`: `3m`, `1hr`, or `1d`. Per-minute limits apply to everyone. diff --git a/recipes/SKILL.md b/recipes/SKILL.md index 1b81998..9f307c2 100644 --- a/recipes/SKILL.md +++ b/recipes/SKILL.md @@ -10,6 +10,7 @@ Use meld for one-time context exchange. It is not for secrets/credentials/regula 1. Create with `POST /api/melds` and share the returned URL. 2. Resolve with `POST /api/melds/{code}/resolve`. 3. Read the live context with `GET /api/melds/{code}`. -4. Mint-next means: create another meld URL and put it in the reply. +4. Bridge time is required: 3m, 1hr, or 1d. The server enforces that TTL. There is no default. MCP: https://meld.mergeinc.workers.dev/mcp +Recipes: https://meld.mergeinc.workers.dev/recipes.md diff --git a/test_freelimit_002.py b/test_freelimit_002.py index 4a7b919..c2ae120 100644 --- a/test_freelimit_002.py +++ b/test_freelimit_002.py @@ -139,8 +139,8 @@ def _unwrap(result): return result -async def do_create(d1, ip, context="x", email=None, headers=None): - body = {"context": context} +async def do_create(d1, ip, context="x", email=None, headers=None, ttl="1hr"): + body = {"context": context, "ttl": ttl} if email: body["email"] = email hdrs = {"x-forwarded-for": ip} @@ -190,7 +190,7 @@ def test_count_created_accumulates(): live = d1.q("SELECT COUNT(*) c FROM melds")[0]["c"] ok("T1.b store is empty", live == 0, f"live={live}") r4 = run(do_create(d1, ip)) - ok("T1.c 4th create walled (429) despite 0 live rows", r4[0] == "err" and r4[1] == 429, + ok("T1.c 4th create admitted (pilot bridges are not quota-walled)", r4[0] == "ok", f"got {r4[0]} {r4[1] if r4[0] == 'err' else 'created'}") @@ -202,23 +202,20 @@ def test_live_rows_still_blocked(): for _ in range(3): run(do_create(d1, ip)) r4 = run(do_create(d1, ip)) - ok("T2 4th concurrent create walled", r4[0] == "err" and r4[1] == 429, f"got {r4[:2]}") + ok("T2 4th concurrent create admitted", r4[0] == "ok", f"got {r4[:2]}") # ── T3: hourly window rolls over ──────────────────────────────────────── def test_window_rolls(): - print("T3 window roll: old window stops counting") + print("T3 pilot: hourly free wall is not applied") d1 = fresh_db() ip = "10.1.0.3" - for _ in range(3): - run(do_create(d1, ip)) - run(do_create(d1, ip)) # wall hit #1 — must NOT offend (NAT guard) - d1.q("UPDATE free_counts SET window_key = '0'") - r = run(do_create(d1, ip)) - ok("T3 create admitted in a fresh window", r[0] == "ok", f"got {r[:2]}") - n = d1.q("SELECT n FROM free_counts WHERE ip=? AND window_key != '0'", ip)[0]["n"] - ok("T3 new-window counter started at 1 (old window not counted)", n == 1, f"n={n}") + results = [run(do_create(d1, ip, context=f"w-{i}")) for i in range(5)] + ok("T3 five creates admitted with explicit ttl", all(r[0] == "ok" for r in results), + str([r[:2] for r in results])) + ok("T3 create does not charge a free-tier counter", + not d1.q("SELECT 1 FROM free_counts WHERE ip=?", ip)) # ── T4: NO lease bypass (no-pro directive) ─────────────────────────────── def test_no_lease_bypass(): @@ -228,74 +225,67 @@ def test_no_lease_bypass(): email = "pro@example.com" results = [run(do_create(d1, ip, email=email)) for _ in range(5)] admitted = sum(1 for r in results if r[0] == "ok") - ok("T4 exactly FREE_LIMIT admits, then wall (no lease bypass)", - admitted == worker.FREE_LIMIT and results[worker.FREE_LIMIT][0] == "err", + ok("T4 email confers no payment and does not wall the 5th create", + admitted == 5 and all(r[0] == "ok" for r in results), str([(r[0], r[1] if r[0] == "err" else "") for r in results])) # ── T5: first wall hit records no offense, no ban ─────────────────────── def test_first_hit_no_offense(): - print("T5 first wall hit: 429 only, no ladder rung") + print("T5 pilot creates do not arm the ban ladder") d1 = fresh_db() ip = "10.1.0.5" for _ in range(3): run(do_create(d1, ip)) r4 = run(do_create(d1, ip)) - ok("T5.a 4th create walled", r4[0] == "err" and r4[1] == 429, f"got {r4[:2]}") + ok("T5.a 4th create admitted", r4[0] == "ok", f"got {r4[:2]}") row = throttle_row(d1, ip) - ok("T5.b violation recorded with 0 offenses", row is not None - and row["wall_hits"] == 1 and row["offense_count"] == 0, + ok("T5.b no throttle row from a free-tier wall", row is None, f"row={dict(row) if row else None}") rej = run(worker._throttle_reject(d1, ip, "/api/melds")) - ok("T5.c no ban after single wall hit", rej is None, f"rej={rej}") + ok("T5.c no ban after ordinary creates", rej is None, f"rej={rej}") # ── T6: second wall hit in a window => rung 1 (1 minute) ──────────────── def test_second_hit_first_rung(): - print("T6 second wall hit: 1-minute ban rung") + print("T6 pilot creates do not record a free-wall offense") d1 = fresh_db() ip = "10.1.0.6" - for _ in range(5): - run(do_create(d1, ip)) # 3 OK + 2 wall hits - row = throttle_row(d1, ip) - ok("T6.a offense 1 recorded", row["offense_count"] == 1, - f"offense_count={row['offense_count']}") - bu = datetime.datetime.fromisoformat(row["banned_until"]) - remain = (bu - datetime.datetime.now(datetime.timezone.utc)).total_seconds() - ok("T6.b ban duration ≈ 60s", 55 <= remain <= 60, f"remaining={remain}") + results = [run(do_create(d1, ip, context=f"p-{i}")) for i in range(5)] + ok("T6.a five creates admitted", all(r[0] == "ok" for r in results), + str([r[:2] for r in results])) + ok("T6.b no ban row", throttle_row(d1, ip) is None) rej = run(worker._throttle_reject(d1, ip, "/api/melds")) - ok("T6.c banned IP gets 429", rej is not None and rej.status_code == 429, - f"rej={rej}") - ok("T6.d Retry-After is real seconds", - rej is not None and 1 <= int(rej.headers.get("Retry-After", "0")) <= 60, - f"headers={dict(rej.headers) if rej else None}") + ok("T6.c IP is not banned", rej is None, f"rej={rej}") # ── T7: ladder walks 1m→10m→1h→24h, capped at rolling 24h (audit F1) ──── def test_ladder_walks(): - print("T7 ladder walk across windows (no worker-issued permanence)") + print("T7 ladder walk across windows via abuse denials (no worker-issued permanence)") d1 = fresh_db() ip = "10.1.0.7" - for _ in range(5): - run(do_create(d1, ip)) # window 1: offense 1 + # Two denials in a window earn one offense. Pilot creates do not do this; + # the per-minute limiter still records denials through _register_wall_hit. + run(worker._register_wall_hit(d1, ip)) + run(worker._register_wall_hit(d1, ip)) expected = [60, 600, 3600, 86400] + row = throttle_row(d1, ip) + remain = (datetime.datetime.fromisoformat(row["banned_until"]) + - datetime.datetime.now(datetime.timezone.utc)).total_seconds() + ok("T7.1 rung 1 ≈ 60s", abs(remain - expected[0]) < 5, f"remaining={remain}") for w in range(2, 9): - # simulate the hourly window rolling over d1.q("UPDATE ip_throttle SET hit_window='0', offense_window='0' WHERE ip=?", ip) - run(do_create(d1, ip)) # hit 1 in new window — no offense - run(do_create(d1, ip)) # hit 2 — offense w + run(worker._register_wall_hit(d1, ip)) + run(worker._register_wall_hit(d1, ip)) row = throttle_row(d1, ip) if w <= 4: - bu = datetime.datetime.fromisoformat(row["banned_until"]) - remain = (bu - datetime.datetime.now(datetime.timezone.utc)).total_seconds() + remain = (datetime.datetime.fromisoformat(row["banned_until"]) + - datetime.datetime.now(datetime.timezone.utc)).total_seconds() ok(f"T7.{w} rung {w} ≈ {expected[w - 1]}s", abs(remain - expected[w - 1]) < 5, f"remaining={remain}") - # audit F1: windows 5..8 — ladder is exhausted, IP rolls 24h bans, - # permanent NEVER set by the worker (legit NAT office must survive) - for w in range(5, 9): - ok(f"T7.{w} offense {w}: permanent stays 0 (worker-capped)", - throttle_row(d1, ip)["permanent"] == 0, - f"permanent={throttle_row(d1, ip)['permanent']}") + else: + ok(f"T7.{w} offense {w}: permanent stays 0 (worker-capped)", + row["permanent"] == 0, f"permanent={row['permanent']}") rej = run(worker._throttle_reject(d1, ip, "/api/melds")) ok("T7.8 walk-to-cap => 429 with Retry-After (recoverable, not 403)", rej is not None and rej.status_code == 429 and "Retry-After" in rej.headers, @@ -329,24 +319,16 @@ def test_webhook_exempt(): # ── T9: NAT simulation — shared IP collects 429s, never a 403 walk ───── def test_nat_shared_ip(): - print("T9 NAT simulation: one IP, many users, single window") + print("T9 pilot creates are not payment-walled") d1 = fresh_db() ip = "10.1.0.9" - for _ in range(3): - run(do_create(d1, ip)) - statuses = [] - for n in range(40): # 40 distinct NAT users slam the shared IP - r = run(do_create(d1, ip, context=f"nat-user-{n}")) - statuses.append(r[1] if r[0] == "err" else 200) - ok("T9.a every response is 429 (never 403)", all(s == 429 for s in statuses), - str(sorted(set(statuses)))) - row = throttle_row(d1, ip) - ok("T9.b exactly ONE offense for the whole window", row["offense_count"] == 1, - f"offense_count={row['offense_count']}") - rej = run(worker._throttle_reject(d1, ip, "/api/melds")) - ok("T9.c shared IP ban is 429+Retry-After, not silent 403", - rej is not None and rej.status_code == 429 - and "Retry-After" in rej.headers, f"rej={rej}") + results = [run(do_create(d1, ip, context=f"nat-user-{n}")) for n in range(6)] + ok("T9.a shared-IP creates stay admitted", all(r[0] == "ok" for r in results), + str([r[:2] for r in results])) + blob = " ".join(str(r) for r in results).lower() + ok("T9.b responses do not upsell checkout", + "checkout" not in blob and "3.33" not in blob, blob[:180]) + ok("T9.c no free-wall ban", throttle_row(d1, ip) is None) # ── T10: per-minute limiter violations also feed the ladder ──────────── @@ -377,21 +359,19 @@ def test_prune(): not throttle_row(d1, "10.9.9.9")) ok("T11.b stale free_counts window pruned", not d1.q("SELECT 1 FROM free_counts WHERE ip='10.9.9.9'")) - ok("T11.c current-window counters survive", - d1.q("SELECT n FROM free_counts WHERE ip='10.1.0.11'")[0]["n"] == 1) + ok("T11.c pilot create does not charge a free-tier counter", + not d1.q("SELECT 1 FROM free_counts WHERE ip='10.1.0.11'")) # ── T12: free-wall 429 carries Retry-After (melde2e finding) ──────────── def test_retry_after_wall(): - print("T12 wall 429 includes real Retry-After seconds") + print("T12 pilot create is not a free-wall 429") d1 = fresh_db() ip = "10.1.0.12" for _ in range(3): run(do_create(d1, ip)) r4 = run(do_create(d1, ip)) - ra = int(r4[2].get("Retry-After", "0")) if r4[0] == "err" else 0 - ok("T12 Retry-After is 1..3600 (time to next hour window)", - r4[0] == "err" and r4[1] == 429 and 1 <= ra <= 3600, f"r4={r4}") + ok("T12 4th create admitted", r4[0] == "ok", f"r4={r4[:2]}") # ── T13: /v1/keys rate-limited (no-pro: no probe-403s anymore) ─────────── @@ -439,48 +419,40 @@ def test_email_inert(): # ── T15: humans skip FREE_LIMIT; agents still walled; pricing header ──── def test_human_skips_free_wall(): - print("T15 humans-free: browser/human header skips FREE_LIMIT; agents wall") + print("T15 pilot-free: humans and agents are not payment-walled") d1 = fresh_db() hip = "10.1.0.15" - # Explicit human header — beyond FREE_LIMIT still admitted results = [run(do_create(d1, hip, context=f"h-{i}", headers={"x-meld-client": "human", - "user-agent": "curl/8.0"})) # header wins over curl + "user-agent": "curl/8.0"})) for i in range(worker.FREE_LIMIT + 2)] - ok("T15.a human header admits past FREE_LIMIT", + ok("T15.a human header admits past the old free limit", all(r[0] == "ok" for r in results), str([r[:2] for r in results])) - # Pricing header on create hdrs = results[0][1].get("_headers", {}) pricing = hdrs.get("x-meld-pricing") or hdrs.get("X-Meld-Pricing") ok("T15.b create carries X-Meld-Pricing", pricing == worker.PRICING_HEADER, f"headers={hdrs}") - # Browser UA, missing X-Meld-Client => human bip = "10.1.0.16" browser = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" bres = [run(do_create(d1, bip, context=f"b-{i}", headers={"user-agent": browser})) for i in range(worker.FREE_LIMIT + 2)] - ok("T15.c browser UA admits past FREE_LIMIT", + ok("T15.c browser UA admits past the old free limit", all(r[0] == "ok" for r in bres), str([r[:2] for r in bres])) - # curl UA (no human header) => agent wall aip = "10.1.0.17" - for i in range(worker.FREE_LIMIT): - run(do_create(d1, aip, context=f"a-{i}", - headers={"user-agent": "curl/8.4.0", "x-meld-client": "agent"})) - r4 = run(do_create(d1, aip, context="a-wall", - headers={"user-agent": "curl/8.4.0"})) - ok("T15.d curl UA walled on 4th", - r4[0] == "err" and r4[1] == 429, f"got {r4[:2]}") - detail_headers = r4[2] if r4[0] == "err" else {} - # HTTPException detail is the message; headers should mention pricing + agent msg path - ok("T15.e agent wall carries X-Meld-Pricing", - (detail_headers or {}).get("X-Meld-Pricing") == worker.PRICING_HEADER - or (detail_headers or {}).get("x-meld-pricing") == worker.PRICING_HEADER, - f"headers={detail_headers}") + ares = [run(do_create(d1, aip, context=f"a-{i}", + headers={"user-agent": "curl/8.4.0", "x-meld-client": "agent"})) + for i in range(worker.FREE_LIMIT + 2)] + ok("T15.d agent creates admitted past the old free limit", + all(r[0] == "ok" for r in ares), str([r[:2] for r in ares])) + blob = " ".join(str(r) for r in ares).lower() + ok("T15.e agent create does not upsell checkout", + "checkout" not in blob and "3.33" not in blob and pricing == "pilot-free", + blob[:180]) for t in [test_count_created_accumulates, test_live_rows_still_blocked, diff --git a/test_pilot_bridges.py b/test_pilot_bridges.py new file mode 100644 index 0000000..ec1cd16 --- /dev/null +++ b/test_pilot_bridges.py @@ -0,0 +1,274 @@ +"""Pilot timed bridges: required 3m/1hr/1d, no payment wall, no body unfurl.""" +import asyncio +import datetime +import json +import sqlite3 +import sys +import types +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +DEPLOY = ROOT / "deploy" +sys.path.insert(0, str(DEPLOY)) + +workers_mod = types.ModuleType("workers") +asgi_mod = types.ModuleType("workers.asgi") +asgi_mod.asgi = lambda app, **kw: app +asgi_mod.entrypoint = lambda app, **kw: app +workers_mod.asgi = asgi_mod +sys.modules["workers"] = workers_mod +sys.modules["workers.asgi"] = asgi_mod + +import worker # noqa: E402 + +SCHEMA = (DEPLOY / "schema.sql").read_text() + "\n" + (DEPLOY / "schema_api.sql").read_text() + """ +CREATE TABLE IF NOT EXISTS funnel_events ( + day TEXT NOT NULL, event TEXT NOT NULL, n INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, event)); +CREATE TABLE IF NOT EXISTS free_counts ( + ip TEXT PRIMARY KEY, window_key TEXT NOT NULL, n INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS ip_throttle ( + ip TEXT PRIMARY KEY, offense_count INTEGER NOT NULL DEFAULT 0, + wall_hits INTEGER NOT NULL DEFAULT 0, hit_window TEXT, offense_window TEXT, + banned_until TEXT, permanent INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL); +""" + +passed = total = 0 + +def ok(name, cond, detail=""): + global passed, total + total += 1 + print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" — {detail}")) + if cond: + passed += 1 + + +class Res: + def __init__(self, changes): + self.meta = {"changes": changes} + def __getitem__(self, k): + return self.meta[k] + + +class Stmt: + def __init__(self, d1, sql): + self.d1, self.sql, self._b = d1, sql, () + def bind(self, *a): + self._b = a + return self + async def first(self): + return self.d1.conn.execute(self.sql, self._b).fetchone() + async def run(self): + cur = self.d1.conn.execute(self.sql, self._b) + return Res(max(cur.rowcount, 0)) + + +class D1: + def __init__(self, conn): + self.conn = conn + def prepare(self, sql): + return Stmt(self, sql) + def q(self, sql, *a): + return self.conn.execute(sql, a).fetchall() + + +def fresh_db(): + conn = sqlite3.connect(":memory:", isolation_level=None) + conn.row_factory = sqlite3.Row + conn.executescript(SCHEMA) + return D1(conn) + + +class FakeRequest: + def __init__(self, d1, body=None, headers=None, path="/api/melds"): + self._body = json.dumps(body or {}).encode() + self.headers = {k.lower(): v for k, v in (headers or {}).items()} + self.scope = { + "type": "http", + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [(k.encode(), v.encode()) for k, v in self.headers.items()], + "client": ("127.0.0.1", 0), + "server": ("test", 80), + "env": types.SimpleNamespace(DB=d1), + } + self.url = types.SimpleNamespace(path=path, scheme="http") + self.client = None + async def json(self): + return json.loads(self._body) + + +def run(coro): + return asyncio.run(coro) + + +def unwrap(result): + raw = result.body + if isinstance(raw, (bytes, bytearray, memoryview)): + raw = bytes(raw).decode() + return json.loads(raw) + + +async def create(d1, ip, body, headers=None): + hdrs = {"x-forwarded-for": ip} + if headers: + hdrs.update(headers) + req = FakeRequest(d1, body, hdrs) + try: + return ("ok", unwrap(await worker.create_meld(req))) + except worker.HTTPException as e: + return ("err", e.status_code, e.detail) + + +def test_ttl_required_and_enforced(): + print("ttl choices") + d1 = fresh_db() + missing = run(create(d1, "10.8.0.1", {"context": "hello"})) + ok("missing ttl is 400", missing[0] == "err" and missing[1] == 400, str(missing)) + bad = run(create(d1, "10.8.0.1", {"context": "hello", "ttl": "1h"})) + ok("1h alias rejected", bad[0] == "err" and bad[1] == 400, str(bad)) + other = run(create(d1, "10.8.0.1", {"context": "hello", "ttl": "7d"})) + ok("7d rejected", other[0] == "err" and other[1] == 400, str(other)) + numeric = run(create(d1, "10.8.0.1", {"context": "hello", "ttl": 180})) + ok("numeric ttl rejected", numeric[0] == "err" and numeric[1] == 400, str(numeric)) + + expect = {"3m": 180, "1hr": 3600, "1d": 86400} + for i, (ttl, seconds) in enumerate(expect.items()): + before = datetime.datetime.now(datetime.timezone.utc) + got = run(create(d1, f"10.8.1.{i}", {"context": f"body-{ttl}", "ttl": ttl})) + ok(f"{ttl} admitted", got[0] == "ok" and got[1]["ttl"] == ttl, str(got)[:180]) + if got[0] != "ok": + continue + exp = datetime.datetime.fromisoformat(got[1]["expires_at"]) + delta = (exp - before).total_seconds() + ok(f"{ttl} expiry within 5s of {seconds}", abs(delta - seconds) < 5, f"delta={delta}") + token = got[1]["owner_token"] + ok(f"{ttl} owner token is 256-bit hex", + isinstance(token, str) and len(token) == 64 and all(c in "0123456789abcdef" for c in token), + token[:12]) + ok(f"{ttl} capability code stays 12 chars", len(got[1]["code"]) == 12, got[1]["code"]) + + +def test_resolve_keeps_chosen_ttl(): + print("resolve keeps ttl") + d1 = fresh_db() + got = run(create(d1, "10.8.2.1", {"context": "keep", "ttl": "1d"})) + code = got[1]["code"] + before = d1.q("SELECT expires_at FROM melds WHERE code=?", code)[0]["expires_at"] + req = FakeRequest(d1, {"context": "reply"}, {"x-forwarded-for": "10.8.2.2"}) + run(worker.resolve_meld(code, req)) + after = d1.q("SELECT expires_at, resolved FROM melds WHERE code=?", code)[0] + ok("resolve does not shrink expires_at", after["expires_at"] == before, f"{before} -> {after['expires_at']}") + ok("resolve still records the reply", after["resolved"] == 1) + + +def test_mcp_selector_has_no_default(): + print("mcp selector") + tool = next(t for t in worker._MCP_TOOLS if t["name"] == "meld_create") + schema = tool["inputSchema"] + ok("ttl is required", schema["required"] == ["context", "ttl"], str(schema["required"])) + ok("ttl enum is exactly 3m/1hr/1d", + schema["properties"]["ttl"]["enum"] == ["3m", "1hr", "1d"], + str(schema["properties"]["ttl"])) + ok("ttl schema has no default", "default" not in schema["properties"]["ttl"]) + card = next(t for t in worker.MCP_SERVER_CARD["tools"] if t["name"] == "meld_create") + ok("server card requires ttl", card["inputSchema"]["required"] == ["context", "ttl"]) + d1 = fresh_db() + req = FakeRequest(d1, {}, {"x-forwarded-for": "10.8.3.1"}) + try: + run(worker._mcp_call_tool("meld_create", {"context": "no time"}, req)) + ok("mcp create without ttl fails", False, "returned a bridge") + except RuntimeError as e: + ok("mcp create without ttl names the three choices", + "3m" in str(e) and "1hr" in str(e) and "1d" in str(e), str(e)) + made = run(worker._mcp_call_tool( + "meld_create", {"context": "with time", "ttl": "3m"}, req)) + ok("mcp create with 3m returns the ttl", made.get("ttl") == "3m" and made.get("share_link"), str(made)[:180]) + + +def test_share_preview_hides_body(): + print("share preview") + secret = "SECRET-BODY-SHOULD-NOT-UNFURL" + d1 = fresh_db() + got = run(create(d1, "10.8.4.1", {"context": secret, "ttl": "3m"})) + req = FakeRequest(d1, None, {"accept": "text/html", "x-forwarded-for": "10.8.4.2"}, path=f"/m/{got[1]['code']}") + resp = run(worker.serve_meld(req, got[1]["code"])) + html = bytes(resp.body).decode() + ok("share html does not contain the meld body", secret not in html) + ok("share title is expires-only", "meld — this bridge expires" in html) + ok("og:title does not carry the body", + 'property="og:title" content="meld — this bridge expires"' in html and secret not in html) + ok("og:description is generic", + 'property="og:description" content="This link expires. The exchange is not included in this preview."' in html) + home = worker._render_page(share=False) + ok("home is the task workspace", "Open a timed bridge" in home and "3 minutes" in home and "1 hour" in home and "1 day" in home) + lowered = home.lower() + ok("home has no mint-next, email field, e2e, or pay upsell", + "mint-next" not in lowered and "type=\"email\"" not in lowered + and "end-to-end" not in lowered and "e2e" not in lowered + and "stripe" not in lowered and "$3.33" not in lowered and "hop-line" not in lowered) + + +def test_receiver_job_and_soft_poll(): + print("receiver job and soft poll") + html = worker.APP_HTML + ok("opener has their own job rail", + 'id="receiver-rail"' in html and "Your job" in html + and ">Read<" in html and ">Reply<" in html and ">Done<" in html) + ok("creator rail stays for the person opening a bridge", + 'id="creator-rail"' in html and "Write the handoff" in html) + receiver = html.split("function receiver(", 1)[1].split("function resolved(", 1)[0] + landing = html.split("function landing(", 1)[1].split("function timeBtn(", 1)[0] + ok("reply page does not render the needs grid", "needsBlock" not in receiver) + ok("create page still explains the handoff", "needsBlock()" in landing) + ok("waiting page polls the existing view endpoint", + "function startWatch(" in html and "scheduleWatch(10000)" in html + and "/api/melds/" in html.split("async function checkResult", 1)[1]) + ok("rate-limit backoff stays on the same read", + "scheduleWatch(45000)" in html and "visibilitychange" in html and "document.hidden" in html) + ok("receiver page does not start the watch", + "stopWatch();" in html.split("function meldPage(", 1)[1].split("function remainingText(", 1)[0] + and "startWatch()" not in html.split("function meldPage(", 1)[1].split("function remainingText(", 1)[0]) + + +def test_legacy_template_paths_404(): + print("legacy template paths") + from fastapi import HTTPException + for path in ("upgrade", "success", "cancel", "error", "app/templates/index.html", "templates/upgrade.html"): + try: + run(worker.serve_page(path)) + ok(f"{path} is not served", False, "returned a page") + except HTTPException as e: + ok(f"{path} is not served", e.status_code == 404, str(e.status_code)) + home = run(worker.serve_page("anything-else")) + body = bytes(home.body).decode() + ok("unknown product paths still get the workspace", "Open a timed bridge" in body) + upgrade = run(worker.upgrade_md()) + text = bytes(upgrade.body).decode() + ok("upgrade.md still served", "pilot" in text.lower() and "$3.33" not in text) + + +def test_shipped_docs_drop_mint_next(): + print("docs") + blob = "\n".join([ + worker.AGENTS_MD, worker.AGENTS_ROOT_MD, worker.LLMS_TXT, worker.SKILL_MD, + worker.RECIPES_MD, worker.TRUST_MD, worker.UPGRADE_MD, worker.TRUST_HTML, + worker.APP_HTML, + ]).lower() + ok("worker product surfaces have no mint-next", "mint-next" not in blob and "mint next" not in blob) + ok("upgrade doc does not sell a bridge", "$3.33" not in worker.UPGRADE_MD and "checkout" not in worker.UPGRADE_MD.lower()) + ok("pricing header is pilot-free", worker.PRICING_HEADER == "pilot-free") + + +for t in (test_ttl_required_and_enforced, test_resolve_keeps_chosen_ttl, + test_mcp_selector_has_no_default, test_share_preview_hides_body, + test_receiver_job_and_soft_poll, test_legacy_template_paths_404, + test_shipped_docs_drop_mint_next): + t() + +print(f"\n{passed}/{total} passed") +sys.exit(0 if passed == total else 1)