diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f2e98e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm -r typecheck + - run: pnpm -r test diff --git a/quickbeam-registry/README.md b/quickbeam-registry/README.md index 007ac80..0c781fc 100644 --- a/quickbeam-registry/README.md +++ b/quickbeam-registry/README.md @@ -57,7 +57,7 @@ and nothing here reads an event log. | Route | Auth | Does | |---|---|---| -| `POST /views` | signature + subscription | create or replace a view (idempotent on `{requester, name}`); returns its id, search URL and MCP command. `hostedMcp: true` also provisions a Cloud Run MCP | +| `POST /views` | signature + subscription | create or replace a view (idempotent on `{requester, name}`); returns its id, search URL and MCP command. `hostedMcp: true` also provisions a Cloud Run MCP. `409` if another of your views already covers exactly these sources | | `GET /views?requester=0x…` | none | that wallet's views (omit `requester` for all) | | `GET /views/{id}` | none | one view | | `GET /watchlist` | none | the deduplicated union the instance polls: `{"sources":[{app, owner, namespace}]}`. `owner`/`namespace` may be `*` (that whole app); dedup is on all three, so the same publisher:subspace in two apps stays two entries | @@ -66,7 +66,18 @@ and nothing here reads an event log. | `GET /q/{id}/stream` | none | SSE: which of the view's domains changed, so a client pulls instead of polling | | `GET /q/{id}/cdn/catalog` | none | the instance catalog **filtered** to the view's domains | | `GET /q/{id}/cdn/*` | none | proxied to the CDN (shards, manifests, edges); `domains/{name}/…` outside the view is `404`, not forwarded | -| `POST /admin/remove` | admin signature | delete a view by `{id}` | +| `POST /views/remove` | requester signature | delete one of **your own** views by `{id}` | +| `POST /admin/remove` | admin signature | delete any view by `{id}` | + +A wallet cannot end up with the same view twice. Sending a view under a name it +already uses **replaces** it — the id is `(requester, name)`, so that is how you change +what a view watches without changing its URLs. Sending the same source set under a +*second* name is refused with `409` naming the view that already covers it: a view is a +filter over the shared collection, so a duplicate would be the same search under two +URLs, two catalogs and two hosted MCPs to keep in step. Sources are compared as a set of +canonical `app:owner:namespace` triples, so order, a repeat, or an app spelled as a name +on one side and its id on the other make no difference. Two *different* wallets asking +for the same namespaces is not a duplicate — that is the whole design. The search **and export** proxies both **strip any caller-supplied `scope`, `owner` or `namespace`** before injecting the view's own pairs, so a view URL always means that @@ -175,8 +186,8 @@ catalog, and returns its URL. An MCP is stateless, holds no connections and need disk, so it scales to zero — an idle user's MCP costs nothing. That is the opposite of the watcher, which is why the watcher lives on a VM. -Unticking the box on a later `POST /views` deletes the service; so does -`POST /admin/remove`. If Cloud Run is unconfigured (`GCP_*` unset), the request returns +Unticking the box on a later `POST /views` deletes the service; so does removing the +view (`POST /views/remove`, `POST /admin/remove`). If Cloud Run is unconfigured (`GCP_*` unset), the request returns an `mcpError` and the view still gets its search endpoint — the feature degrades, it does not fail the view. @@ -196,21 +207,32 @@ created against, and match the watcher's `APP`, or their catalogs come back empt ## Teardown -Nothing expires on its own — a lapsed subscription keeps running until someone removes -its view. That is deliberate: teardown is a founder action. +Nothing expires on its own — a view keeps being watched until somebody removes it. +Two routes, because they answer to different people: + +**`POST /views/remove` — the requester's own.** Ownership only: the signing wallet must +be the view's `requester`, and an admin wallet gets **no** override here (that is what +`/admin/remove` is for). There is no subscription check either — a wallet whose +subscription has lapsed must still be able to stop being watched. ```sh -# Any wallet in ADMIN_WALLETS. Two steps: collect the challenge, sign, resend. -curl -s -X POST "$WORKER/admin/remove" -H 'content-type: application/json' \ - -d '{"address":"0xYOURADMIN","id":"qb_147c24c5_music"}' +# Two steps, like every write here: collect the challenge, sign it, resend. +curl -s -X POST "$WORKER/views/remove" -H 'content-type: application/json' \ + -d '{"address":"0xYOU","id":"qb_147c24c5_music"}' # → { "challenge": "..." } sign it, then: -curl -s -X POST "$WORKER/admin/remove" -H 'content-type: application/json' \ - -d '{"address":"0xYOURADMIN","id":"qb_147c24c5_music","message":"","signature":"0x…"}' +curl -s -X POST "$WORKER/views/remove" -H 'content-type: application/json' \ + -d '{"address":"0xYOU","id":"qb_147c24c5_music","message":"","signature":"0x…"}' ``` -Removing a view drops its sources from the watchlist **only if no other view wants -them**. Add a founder by appending to `ADMIN_WALLETS` in `wrangler.toml` and -redeploying. +The website exposes this as **Stop watching** inside each view on the dashboard. + +**`POST /admin/remove` — any view.** Same body, same handshake, but the signer must be +in `ADMIN_WALLETS`. Add a founder by appending to it in `wrangler.toml` and redeploying. + +Either route deletes the view's row, its `dns:` label and its hosted MCP (a Cloud Run +failure comes back as `207` + `mcpError`; the row is gone regardless). Its sources leave +the watchlist **only if no other view wants them** — the instance cancels its stream +from that on-chain head once the last view referencing it goes. ## Configuration @@ -236,6 +258,7 @@ A `[build]` guard aborts the deploy if the installed SDK carries no valid addres | `ADMIN_WALLETS` | comma-separated wallets allowed to tear down | | `MAX_VIEWS_PER_WALLET` | abuse cap, not an entitlement; `"0"` disables | | `SEARCH_URL`, `CDN_URL` | the instance, plain HTTP | +| `INSTANCE_TIMEOUT_MS` | how long the instance may take to *answer* a proxied request before it is a `502` (default `15000`). Bounds time-to-headers only — `/stream` and `/export` still stream for as long as they like | | `VIEW_DOMAIN_SUFFIX` | per-view subdomains live under this; empty serves views at `/q/{id}/…` | | `GCP_PROJECT`, `GCP_REGION`, `GCP_SA_EMAIL`, `QUICKBEAM_IMAGE` | hosted MCP; unset disables the feature | | `GCP_SA_KEY` (**secret**) | the service-account JSON's `private_key`; needs `roles/run.admin` + `roles/iam.serviceAccountUser` | @@ -273,7 +296,12 @@ production. ## Known ceilings -- `GET /watchlist` and `GET /views` read every row. KV lists page at 1000 keys; fine - for a prototype, and the first thing to change if this becomes the product. +- `GET /watchlist` and `GET /views` read every row, cached in one `snapshot:views` key + (10 min TTL, dropped on every write) so a poll costs a `get` and not a KV **list** — + the op capped at 1000/day on the free plan, which an instance polling every 60s + exceeds on its own. Editing a `view:` row directly with `wrangler kv key put` does + NOT invalidate it: delete `snapshot:views` too, or wait out the TTL. +- The rebuilt snapshot is one value, and KV lists page at 1000 keys; fine for a + prototype, and the first thing to change if this becomes the product. - KV is eventually consistent, so a just-created view can take a moment to appear in `/watchlist`. The instance converges on the next poll either way. diff --git a/quickbeam-registry/examples/README.md b/quickbeam-registry/examples/README.md index e88026a..7f34c0a 100644 --- a/quickbeam-registry/examples/README.md +++ b/quickbeam-registry/examples/README.md @@ -56,7 +56,8 @@ The script exits non-zero on a refusal and names it: |---|---| | `202` / `200` | created / replaced, or removed | | `402` | that wallet has no active subscription | -| `403` | not a registered publisher, or not an admin for `/admin/remove` | +| `403` | not a registered publisher; not an admin for `/admin/remove`; not the requester for `/views/remove` | +| `409` | another of your views already covers exactly these sources | | `401` | the signature did not verify — wrong key for that address | | `429` | per-wallet view cap (`MAX_VIEWS_PER_WALLET`) | diff --git a/quickbeam-registry/examples/manage-views.mjs b/quickbeam-registry/examples/manage-views.mjs index 17308db..532ee84 100644 --- a/quickbeam-registry/examples/manage-views.mjs +++ b/quickbeam-registry/examples/manage-views.mjs @@ -13,8 +13,11 @@ * node examples/manage-views.mjs --worker https://…workers.dev --key 0xKEY \ * --name my-view --source 0xOWNER:namespace [--source 0xOWNER:other] [--hosted-mcp] * - * node examples/manage-views.mjs --worker https://…workers.dev --key 0xADMINKEY \ - * --remove qb_147c24c5_my-view + * node examples/manage-views.mjs --worker https://…workers.dev --key 0xKEY \ + * --remove qb_147c24c5_my-view [--admin] + * + * --remove deletes YOUR OWN view (the wallet that created it). --admin sends it to + * the founder route instead, which is the only way to remove somebody else's. * * node examples/manage-views.mjs --worker https://…workers.dev --watchlist * @@ -33,6 +36,7 @@ function parseArgs(argv) { else if (arg === '--name') out.name = next(); else if (arg === '--source') out.sources.push(next()); else if (arg === '--remove') out.remove = next(); + else if (arg === '--admin') out.admin = true; else if (arg === '--hosted-mcp') out.hostedMcp = true; else if (arg === '--watchlist') out.watchlist = true; else throw new Error(`Unknown argument: ${arg}`); @@ -104,8 +108,9 @@ async function main() { console.log(`address: ${account.address}`); if (args.remove) { - report(`POST /admin/remove ${args.remove}`, - await signedPost(worker, '/admin/remove', account, { id: args.remove })); + const route = args.admin ? '/admin/remove' : '/views/remove'; + report(`POST ${route} ${args.remove}`, + await signedPost(worker, route, account, { id: args.remove })); return; } diff --git a/quickbeam-registry/src/index.js b/quickbeam-registry/src/index.js index eb1a562..7425ad2 100644 --- a/quickbeam-registry/src/index.js +++ b/quickbeam-registry/src/index.js @@ -18,7 +18,8 @@ * it embedded. And there is no access control on reads: the source graphs are public * on-chain data, so a view id is a convenience, not a secret. Writes are gated * (signature + active subscription) because creating a view is what spends embedding - * work. + * work. Removing one spends nothing, so it needs only the signature — and only the + * requester's own: unwatching is theirs to do, not an admin's. * * Dependencies are `viem` (signature recovery + selector encoding) and the Fangorn * SDK, which supplies the deployment addresses. @@ -107,10 +108,12 @@ export default { // falling through to "provide a valid EVM address" for a mistyped *path* sends the // reader hunting for an auth problem that does not exist — which is exactly what a // watcher pointed at the old `/sources` route used to see. - if (url.pathname !== '/views' && url.pathname !== '/admin/remove') { + if (url.pathname !== '/views' && url.pathname !== '/views/remove' + && url.pathname !== '/admin/remove') { return json(404, { error: `Unknown route ${url.pathname}`, routes: ['GET /watchlist', 'GET /views', 'GET /views/{id}', 'POST /views', + 'POST /views/remove', 'GET /q/{viewId}/search', 'GET /q/{viewId}/export', 'GET /q/{viewId}/stream', 'GET /q/{viewId}/cdn/*', 'POST /admin/remove'], @@ -131,25 +134,24 @@ export default { return json(401, { ok: false, address, error: ownership.error, challenge: ownership.challenge }, cors); } - // ── Founder-only teardown ─────────────────────────────────────────────── - if (url.pathname === '/admin/remove') { - if (!isAdmin(env, address)) return json(403, { error: 'Not an admin wallet.' }, cors); + // ── Teardown ──────────────────────────────────────────────────────────── + // Two routes, same deletion, different people. `/views/remove` is how a requester + // stops watching what they asked for: OWNERSHIP ONLY — an admin wallet gets no + // pass here, and there is no subscription check, because unwatching has to keep + // working after the subscription that created the view lapses. Dropping the last + // view over a namespace is what takes it off /watchlist and cancels the + // instance's stream from that on-chain head. + if (url.pathname === '/views/remove' || url.pathname === '/admin/remove') { + const asAdmin = url.pathname === '/admin/remove'; const id = (input.id || '').trim(); const view = await readView(env, id); if (!view) return json(404, { error: `No view ${id}` }, cors); - - // Delete the hosted MCP before the row, so a failure here leaves the view - // visible rather than orphaning a Cloud Run service nothing points at. - let mcpError = null; - if (view.mcp) { - try { - await deleteMcpService(env, id); - } catch (err) { - mcpError = err.message; - } + if (asAdmin ? !isAdmin(env, address) : view.requester !== address) { + return json(403, { + error: asAdmin ? 'Not an admin wallet.' : `View ${id} belongs to another wallet.`, + }, cors); } - await env.QUICKBEAM_KV.delete(viewKey(id)); - await env.QUICKBEAM_KV.delete(dnsKey(dnsLabel(id))); + const mcpError = await deleteView(env, id, view); return json(mcpError ? 207 : 200, { ok: !mcpError, removed: id, ...(mcpError ? { mcpError } : {}) }, cors); } @@ -187,18 +189,30 @@ export default { const id = viewId(address, name); const existing = await readView(env, id); + const mine = (await listViews(env)).filter((v) => v.requester === address); + + // The same sources under a SECOND name is an accident, not a feature: a view is a + // filter over the shared collection, so two of them over one source set are the + // same search twice — two URLs, two catalogs and (hosted) two Cloud Run services + // to keep in step. Re-sending a view under its own name is the intended way to + // change one; that lands on the same `id` and replaces, which is why the twin + // search skips it. + const twin = mine.find((v) => v.id !== id && sameSources(v.sources, sources, env)); + if (twin) { + return json(409, { + ok: false, + error: `You already have a view over these sources, called "${twin.name}". ` + + `Use it, or name this one "${twin.name}" to replace it.`, + existing: withUrls(twin, url, env), + }, cors); + } - if (!existing) { - const cap = Number(env.MAX_VIEWS_PER_WALLET || 0); - if (cap > 0) { - const mine = (await listViews(env)).filter((v) => v.requester === address); - if (mine.length >= cap) { - return json(429, { - ok: false, - error: `This wallet already has ${mine.length} view(s), the current limit.`, - }, cors); - } - } + const cap = Number(env.MAX_VIEWS_PER_WALLET || 0); + if (!existing && cap > 0 && mine.length >= cap) { + return json(429, { + ok: false, + error: `This wallet already has ${mine.length} view(s), the current limit.`, + }, cors); } const view = { @@ -228,6 +242,7 @@ export default { await env.QUICKBEAM_KV.put(viewKey(id), JSON.stringify(view)); await env.QUICKBEAM_KV.put(dnsKey(dnsLabel(id)), id); + await invalidateViews(env); return json(existing ? 200 : 202, { ok: true, ...withUrls(view, url, env), @@ -300,6 +315,19 @@ function toAppId(nameOrId) { const withApp = (s, env) => ({ app: toAppId(s.app || env.DEFAULT_APP), owner: s.owner || '*', namespace: s.namespace || '*' }); +/** + * Do two source lists cover exactly the same thing? Compared as a set of canonical + * triples, so order, a repeated source, or an app spelled as a name on one side and as + * its id on the other cannot make a duplicate look new. + */ +function sameSources(a, b, env) { + const key = (sources) => [...new Set(sources.map((s) => { + const { app, owner, namespace } = withApp(s, env); + return `${app}:${owner}:${namespace}`; + }))].sort().join('|'); + return key(a) === key(b); +} + /** * CDN domain for one source. MUST match `_domain_for` in quickbeam/watcher.py — the * watcher names the directory, this names it back to filter a view's catalog. Byte @@ -405,6 +433,7 @@ async function withMcpStatus(env, view) { const updated = { ...view, mcp: { ...view.mcp, status: live.status, url: live.url } }; const { searchUrl, cdnUrl, mcpCommand, ...row } = updated; await env.QUICKBEAM_KV.put(viewKey(view.id), JSON.stringify(row)); + await invalidateViews(env); // the cached set still holds the pre-URL row return updated; } } catch { @@ -425,17 +454,60 @@ async function ensureMcp(env, view, url) { return { service: serviceName(view.id), status: live.status, url: live.url ?? null }; } +/** + * Delete a view and everything provisioned for it, returning the Cloud Run error if + * the hosted MCP could not be torn down. The MCP goes first, so a failure there is + * reported (207) rather than silently orphaning a service nothing points at; the row + * goes either way, because a view whose delete half-failed is worse than a stray + * service an admin can sweep. + */ +async function deleteView(env, id, view) { + let mcpError = null; + if (view.mcp) { + try { + await deleteMcpService(env, id); + } catch (err) { + mcpError = err.message; + } + } + await env.QUICKBEAM_KV.delete(viewKey(id)); + await env.QUICKBEAM_KV.delete(dnsKey(dnsLabel(id))); + await invalidateViews(env); + return mcpError; +} + async function readView(env, id) { if (!id) return null; const raw = await env.QUICKBEAM_KV.get(viewKey(id)); return raw ? JSON.parse(raw) : null; } +/** One key holding every view, so the common read is a `get` and not a `list`. */ +const SNAPSHOT_KEY = 'snapshot:views'; +/** Rebuild at most this often. A missed invalidation self-heals within one TTL. */ +const SNAPSHOT_TTL = 600; + /** - * Every view. KV list pages at 1000 keys — fine for a prototype, and the ceiling to - * remember before this becomes the product. + * Every view, from one cached key. + * + * The uncached form — `list({prefix:'view:'})` plus a `get` per row — was a LIST + * OPERATION ON EVERY REQUEST, and `/watchlist` is polled by each instance forever: + * at --sources-refresh=60 that is 1440/day against a free-plan ceiling of 1000, before + * the website's own /views calls. Cached, a poll is one `get` (100k/day) and the list + * runs at most once per SNAPSHOT_TTL — 144/day at the current value. + * + * Writers call `invalidateViews`, so the TTL is a safety net for the paths that do not: + * a `wrangler kv key put` straight into the namespace shows up within one TTL, or + * immediately if you delete this key too. + * + * ponytail: one key for the whole set. KV list pages at 1000 keys and a value caps at + * 25 MB, so the ceiling is the same prototype ceiling as before — per-app snapshot keys + * if a deployment ever outgrows it. */ async function listViews(env) { + const cached = await env.QUICKBEAM_KV.get(SNAPSHOT_KEY); + if (cached) return JSON.parse(cached); + const out = []; let cursor; do { @@ -446,9 +518,18 @@ async function listViews(env) { } cursor = page.list_complete ? undefined : page.cursor; } while (cursor); + await env.QUICKBEAM_KV.put(SNAPSHOT_KEY, JSON.stringify(out), { expirationTtl: SNAPSHOT_TTL }); return out; } +/** + * Drop the cached set. Called after every write, BEFORE the response goes out, so the + * caller's next read cannot see the set it just changed as stale — KV's own eventual + * consistency (up to 60s) is the remaining window, and it applied to the uncached form + * just the same. + */ +const invalidateViews = (env) => env.QUICKBEAM_KV.delete(SNAPSHOT_KEY); + /* ───────────────────────────── entitlement ──────────────────────────────── */ /** @@ -630,7 +711,7 @@ async function proxy(request, env, url, cors, resolved = null) { headers.delete('host'); let upstream; try { - upstream = await fetch(target, { + upstream = await instanceFetch(target, env, { method: request.method, headers, body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body, @@ -658,7 +739,7 @@ async function proxy(request, env, url, cors, resolved = null) { async function filteredCatalog(env, view, cors) { let catalog; try { - const res = await fetch(`${trimSlash(env.CDN_URL)}/catalog`); + const res = await instanceFetch(`${trimSlash(env.CDN_URL)}/catalog`, env); if (!res.ok) return json(502, { error: `Catalog HTTP ${res.status}` }, cors); catalog = await res.json(); } catch (err) { @@ -683,7 +764,7 @@ async function resolveDomains(env, view) { } const match = domainMatcher(view, env); try { - const res = await fetch(`${trimSlash(env.CDN_URL)}/catalog`); + const res = await instanceFetch(`${trimSlash(env.CDN_URL)}/catalog`, env); if (!res.ok) return []; const catalog = await res.json(); return (catalog.domains || []).map((d) => d.name).filter(match); @@ -692,6 +773,33 @@ async function resolveDomains(env, view) { } } +/** + * A fetch at the shared instance, bounded on how long it may take to ANSWER — not on + * how long it may stream. + * + * `SEARCH_URL`/`CDN_URL` are a grey-cloud A record straight at a VM, so a box that is + * off, or a firewall that DROPs instead of refusing, black-holes the SYN. A plain + * fetch then hangs until Cloudflare gives up around 100s and the `catch` below never + * runs — which reads to a caller as "the endpoint is timing out" when what happened is + * "the instance is unreachable", the single most misleading failure this worker has. + * + * The timer is cleared the moment headers arrive, so `/stream` (SSE, open for hours) + * and `/export` (a whole corpus) stream for as long as they like — only the wait for + * a first answer is capped. + */ +async function instanceFetch(url, env, init) { + const ms = Number(env.INSTANCE_TIMEOUT_MS || 15000); + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), ms); + try { + return await fetch(url, { ...init, signal: abort.signal }); + } catch (err) { + throw abort.signal.aborted ? new Error(`no answer within ${ms}ms`) : err; + } finally { + clearTimeout(timer); + } +} + const trimSlash = (s) => (s || '').replace(/\/$/, ''); /** diff --git a/quickbeam-registry/test/index.test.js b/quickbeam-registry/test/index.test.js index fbc88ad..f3f9051 100644 --- a/quickbeam-registry/test/index.test.js +++ b/quickbeam-registry/test/index.test.js @@ -302,6 +302,30 @@ test('re-creating a view replaces it and keeps createdAt', async () => { assert.equal(viewCount(), 1); }); +test('the same sources under a second name is refused, not duplicated', async () => { + const first = await (await createView(alice, 'music', [src(PUB, 'x')])).json(); + + const dupe = await createView(alice, 'tunes', [src(PUB, 'x')]); + assert.equal(dupe.status, 409); + const body = await dupe.json(); + assert.match(body.error, /music/); + assert.equal(body.existing.id, first.id); + assert.equal(viewCount(), 1); + + // Order, a repeat, and the app spelled as a name rather than its id are all the same + // source set — none of them is a way in. + const shuffled = await createView(alice, 'tunes', [ + src(PUB2, 'y'), src(PUB, 'x'), src(PUB, 'x', APP_NAME), + ]); + assert.equal(shuffled.status, 202, 'a genuinely different set still creates'); + assert.equal((await createView(alice, 'more', [src(PUB, 'x'), src(PUB2, 'y')])).status, 409); + assert.equal(viewCount(), 2); + + // Another wallet asking for the same namespaces is not a duplicate — that is the + // whole point of embedding once and filtering per requester. + assert.equal((await createView(bob, 'music', [src(PUB, 'x')])).status, 202); +}); + test('an unregistered wallet is told to register (403)', async () => { rpcResponse = () => accessResult(false, 0); const res = await createView(alice, 'v', [src(PUB, 'ns')]); @@ -487,6 +511,21 @@ test('cdn passthrough refuses a domain outside the view', async () => { assert.equal(lastInstanceUrl, null, 'must not reach the instance at all'); }); +test('a box that never answers is a 502, not a hang', async () => { + const v = await (await createView(alice, 'mine', [src(PUB, 'a')])).json(); + env.INSTANCE_TIMEOUT_MS = '50'; + // A black hole, not a refusal: an off box or a DROP firewall never completes the + // handshake, so without the bound this request runs until Cloudflare kills it ~100s + // later and the caller is told "timed out" about the wrong hop. + instanceResponse = (href, init) => new Promise((_, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason)); + }); + + const res = await get(`/q/${v.id}/stream`); + assert.equal(res.status, 502); + assert.match((await res.json()).error, /no answer within 50ms/); +}); + test('an unrecognised proxy path is 404 rather than forwarded somewhere', async () => { const v = await (await createView(alice, 'mine', [src(PUB, 'a')])).json(); const res = await get(`/q/${v.id}/admin/secrets`); @@ -526,6 +565,35 @@ test('views are readable without a signature', async () => { assert.equal(theirs.views.length, 0); }); +test('a requester can remove their own view, and only their own', async () => { + const v = await (await createView(alice, 'mine', [src(PUB, 'a')])).json(); + + // Neither a stranger nor an admin wallet can remove somebody else's view here — + // /views/remove answers to the requester alone. + assert.equal((await signedPost('/views/remove', bob, { id: v.id })).status, 403); + assert.equal((await signedPost('/views/remove', admin, { id: v.id })).status, 403); + assert.equal(viewCount(), 1); + + const ok = await signedPost('/views/remove', alice, { id: v.id }); + assert.equal(ok.status, 200); + assert.equal(viewCount(), 0); + // Off the watch list: the instance stops following that on-chain head. + assert.deepEqual((await (await get('/watchlist')).json()).sources, []); +}); + +test('removing a view needs a signature, and works without a subscription', async () => { + const v = await (await createView(alice, 'mine', [src(PUB, 'a')])).json(); + + const unsigned = await post('/views/remove', { address: alice.address, id: v.id }); + assert.equal(unsigned.status, 401); + assert.equal(viewCount(), 1); + + // A lapsed subscription must not trap a requester into being watched forever. + rpcResponse = () => accessResult(true, 0); + assert.equal((await signedPost('/views/remove', alice, { id: v.id })).status, 200); + assert.equal(viewCount(), 0); +}); + test('teardown is admin-only', async () => { const v = await (await createView(alice, 'mine', [src(PUB, 'a')])).json(); const denied = await signedPost('/admin/remove', alice, { id: v.id }); @@ -652,3 +720,45 @@ test('an unknown subdomain is 404, not a proxy to nowhere', async () => { const res = await worker.fetch(new Request('https://qb-dead-beef.qb.sond3r.com/search?q=x'), env); assert.equal(res.status, 404); }); + +/* ── watch-list caching ──────────────────────────────────────────────────── */ + +/** Count `list` operations from here on — the KV op with a 1000/day free-plan cap. */ +function countLists(kv) { + const real = kv.list.bind(kv); + let n = 0; + kv.list = async (opts) => { n += 1; return real(opts); }; + return () => n; +} + +test('POLLING IS A GET, NOT A LIST: repeated /watchlist lists KV once', async () => { + // Every instance polls /watchlist forever. Uncached this was one list operation per + // poll — 1440/day at --sources-refresh=60 against a free-plan ceiling of 1000, which + // is what took the real deployment over its quota. + await createView(alice, 'music', [src(PUB, 'tracks')]); + const lists = countLists(env.QUICKBEAM_KV); + + for (let i = 0; i < 5; i += 1) await get('/watchlist'); + + assert.equal(lists(), 1, 'the set must be rebuilt once, then served from the snapshot'); +}); + +test('a view created after the cache was warmed still reaches the watch list', async () => { + await createView(alice, 'music', [src(PUB, 'tracks')]); + await get('/watchlist'); // warm it + + await createView(bob, 'more', [src(PUB2, 'other')]); + + const { sources } = await (await get('/watchlist')).json(); + assert.equal(sources.length, 2, 'a stale snapshot would leave the new namespace unwatched'); +}); + +test('removing a view drops it from the watch list immediately', async () => { + const v = await (await createView(alice, 'music', [src(PUB, 'tracks')])).json(); + await get('/watchlist'); // warm it + + await signedPost('/admin/remove', admin, { id: v.id }); + + const { sources } = await (await get('/watchlist')).json(); + assert.deepEqual(sources, [], 'teardown must not wait out the snapshot TTL'); +}); diff --git a/quickbeam-registry/wrangler.toml b/quickbeam-registry/wrangler.toml index 459e73b..ecdfa1a 100644 --- a/quickbeam-registry/wrangler.toml +++ b/quickbeam-registry/wrangler.toml @@ -97,6 +97,16 @@ POLL_HINT_SECONDS = "60" SEARCH_URL = "http://qb.sond3r.com:8080" CDN_URL = "http://qb.sond3r.com:8090" +# How long the instance may take to ANSWER before a proxied request is a 502. Bounds +# the wait for headers only — /stream (SSE) and /export keep streaming afterwards for +# as long as they like. +# +# A box that is off, or a firewall that DROPs rather than refuses, black-holes the SYN: +# without this the request hangs until Cloudflare kills it near 100s, and the caller is +# told "the endpoint timed out" about the wrong hop entirely. 502 "Instance unreachable" +# in 15s names the real one. +INSTANCE_TIMEOUT_MS = "15000" + # ── App scoping ────────────────────────────────────────────────────────────── # A source is the whole app:publisher:subspace triple. Apps are stored canonically as # their 32-byte id (keccak256 of the name, the SDK's toAppId) — the website only ever