Releases: spoo-me/spoo
Release list
v2.4.0
Links can carry tags, and the block ping says what happens to the next link.
Tags are a registry, not strings on the link. Every account gets a tags collection: a tag has a name (lowercased, unique per account, 32 characters), one of nine colours, and an icon from a curated set of 87 keys that the API validates and publishes as an enum in openapi.json. Links store tag_ids and every link response embeds tags: [{id, name, color, icon}] in the link's own order. Rename or recolour a tag once and every link follows.
Four endpoints and a bulk op. GET, POST on /api/v1/tags, PATCH and DELETE on /api/v1/tags/{tag_id}; delete strips the tag from every link and reports how many. POST /api/v1/urls/bulk/tags takes {ids, add, remove} and applies both in one pipeline update per request, so a link never sits between losing its old tags and gaining the new ones. Create and PATCH on links take tag_ids as a whole-list replace; every id must be yours.
Filters take ids or names. The links list accepts tagIds and tagNames inside filter with tagsMatch: any | all. Stats and export accept tag_id and tag, resolved to the owner's link ids since clicks never carry tags; a name no tag carries matches nothing, and group_by=tag is refused because it would need a per-click join. Tags are not on the redirect cache projection, so nothing on the hot path reads them. Account erasure deletes the owner's tags; webhook link.* snapshots carry tag_ids.
The block embed says what happens next. A Discord block ping now has a Future links field derived from the block's scope: a host block refuses new links to the host at create, a pattern block refuses matching paths and leaves the rest of the host open, a links block refuses only that URL. The pattern branch used to claim the pattern was proposed for the operator blocklist. Nothing in the pipeline writes to that list, so the claim is gone and a test keeps it gone.
What's Changed
- feat(links): tags as a per-account registry with ids, colours and icons by @Zingzy in #352
- fix(safety): say what a block does to future links, drop blocklist claim by @Zingzy in #338
Full Changelog: v2.3.6...v2.4.0
v2.3.6
The safety pings say how far a block reached, and show the page the model judged.
Scope is its own field. A block embed used to say what host and how many links, but not whether that meant the whole domain, one path pattern, or a single link; on some paths the scope was a clause inside the reason, on others it was absent. Every block embed now carries a dedicated Scope line, Reason is only the model's reason, and the screening tier's next step (sent to investigation, needs review, feed listing is the answer) is a separate Follow-up line so a link-scoped block never reads "host-wide" beside a scope that says otherwise.
The screenshot travels with the verdict. The browser render the model judged was captured, handed to the model, and discarded when the run ended. It now rides on both the block and the review embed as an attachment. Every render in an investigation is kept and the one attached is the render of the judged URL, not the last one, because the model is told to fetch the destination and then the domain root and last-wins would have shown a blank root beside a reason describing the gate.
The shared notifier stays shared. infrastructure/ops_notify now exposes one generic embed call that owns everything Discord: routing with an unknown channel refused rather than misrouted, the footer, the 1,024-character field cap so a long pattern clips on display instead of losing the whole notification, an image as a multipart attachment, https-only webhook URLs, and no redirect following. Everything safety-shaped moved to services/safety/notify.py.
What's Changed
Full Changelog: v2.3.5...v2.3.6
v2.3.5
The deep tier stops hedging on the one thing it sees most often, and stops rolling dice.
A fake verification gate is the payload. Seven of eighteen medium-confidence scam verdicts were hand-drawn "Security Check / I'm not a robot" cards on days-old throwaway domains. The model described them accurately, named ClickFix in its own reasoning, and said medium, so the auto-block policy did nothing. The prompt's only confidence rule was "high means you would stake the block on it", and every high example showed the model seeing the payload directly; nothing said the gate on a young spoofing domain is the payload. It does now, with two conditions together: a rendered gate that loads no real challenge provider, plus at least two of a young certificate, a numeric or random hostname, brand imitation, no mail records, or nothing legitimate at the root. A dead page is never a gate. A real provider challenge on an established domain stays missing evidence.
Side by side on the same 17 hosts with the real tools and model: the one live gate went medium to high, a dead page that the first draft had wrongly pushed to high dropped back to uncertain, and nothing benign was condemned in any run.
The tool stops contradicting the rule. The challenge-page note from v2.3.4 fired on wording alone, so a kit that copies Cloudflare's text verbatim was told "missing evidence" by the tool while the rule said judge it. The renderer now records whether a real challenge provider script loaded. With one, the note stands. Without one, it says the opposite: a real challenge always loads its provider, so this is a fake gate and positive evidence.
Sampling at temperature 0. The runner was passing only a timeout, so every investigation sampled at the provider default of 1.0, and the same evidence could land on different verdicts run to run. Judgment tasks now sample at 0 by default, configurable with LLM_TEMPERATURE.
What's Changed
Full Changelog: v2.3.4...v2.3.5
v2.3.4
Three changes: the deep safety tier gets decisive, the legacy API docs page stops being served, and one alias is reserved.
Auto-blocking works again. The deep tier judged 16 destinations as scams at high confidence over three days and blocked none of them, because the policy required an external feed to agree first, and anything a feed knows has already been blocked upstream. That policy is now confident: a high-confidence verdict blocks on its own. A second bug hid behind the first: only a report could name the links to block, so a sweep-found scam at links scope enforced nothing. It now blocks the judged URL.
Live pages were being called dead. The renderer waited for zero in-flight network requests, so one analytics beacon kept a real page "loading" until it timed out and it was reported as unreachable. It now tries a looser wait, but only when the failure was a wait timeout rather than a dead host. Frameset and meta-refresh targets are surfaced instead of reported as "no content", an anti-bot interstitial is named as missing evidence, and the screenshot the browser already captured now reaches the model.
Coverage sweeps stop paging the operator. A sweep that finds nothing conclusive records its verdict and stays quiet, the rule the screening tier already followed. A proposal to add a service to a list still gets through on any trigger, because it carries something to approve.
Redirector verdicts close the loop. Every redirector_service verdict now proposes the service into either the shorteners list (new links refused) or the redirectors list (resolved only). High-confidence redirectors proposals apply themselves; shorteners proposals wait for a human and are persisted so that tap has something to act on.
The legacy API docs page is gone. spoo.me/api?old=1 still rendered the full pre-Mintlify docs: 81 KB, indexable, its own title, competing with the real docs for the same queries while telling the reader they had moved. Its canonical pointed at /api, itself a 301, which made the signal worse rather than better. The branch is removed and /api redirects like every other docs path.
restore-account is reserved. The frontend serves that path for the account-deletion cancel link and Caddy routes it to Next, so a short link with that alias could never resolve. Reserving it keeps the namespace honest.
What's Changed
- chore: reserve the restore-account alias by @Zingzy in #330
- fix(docs): stop serving the legacy API docs page, drop a redirect hop by @Zingzy in #333
- fix(safety): make L2 decisive, honest about renders, and quiet on sweeps by @Zingzy in #335
Full Changelog: v2.3.3...v2.3.4
v2.3.3
Safety screening stops buying a second opinion it already has.
Feed hits skip investigation. A threat feed naming a host is the host-wide answer, so there is nothing left for the deep tier to decide. Every toxic screening hit escalated regardless, and on the first feed sync that meant 51 investigations returning "yes, this host is a scam" about hosts screening had already blocked. Narrower verdicts still escalate, because how far a link-scoped finding reaches is a real question.
Blocks that reach further than the evidence are now visible. A shortener or share wrapper lands on a scam feed for what routed through it, not for what it is, so blocking the host takes every unrelated link with it. Enforcement is unchanged and still follows the feed, but these cases now log safety_feed_block_on_shared_carrier with the host and a query-stripped sample URL, so they can be found rather than passing silently.
What's Changed
Full Changelog: v2.3.2...v2.3.3
v2.3.2
A startup fix for the click worker's safety wiring.
Web Risk in the worker. The click worker builds its own safety providers rather than sharing the app's wiring. When the Web Risk provider moved onto a shared client, the app wiring was updated and the worker was not, so enabling the safety pipeline crash-looped the worker on startup while the app itself booted normally. Both worker call sites now build through the same client.
The runtime builder also gets tests that boot the screening and investigation branches, so a provider signature change fails a test instead of a deploy.
What's Changed
Full Changelog: v2.3.1...v2.3.2
v2.3.1
A fix for the link preview checker, plus the deployment config it runs on.
Meta tags on very large pages. The checker read the first 512KB of a page on the assumption that tags sit early in the head. Youtube puts roughly 700KB of inline JSON before its meta tags, so the read stopped short and the tool reported a page with nine missing tags rather than one it could not finish reading. The fetch now ends at </head> with the byte cap kept as a backstop, which also makes ordinary pages cheaper to read than before: github closes its head at 32KB, nytimes at 190KB.
Deployment config. The Caddyfile, compose file and vector config in this repo had drifted from what runs in production, missing the frontend container entirely. They now match, so the deployment is reviewable and recoverable from git. Part of that: the spoo.me name moved from a network alias to the frontend container's hosts file, so the app resolves its own domain publicly and the link tools can fetch spoo.me URLs.
What's Changed
- chore(compose): sync the prod file and scope the spoo.me name to the frontend by @Zingzy in #326
- chore(vector): ship the frontend container's logs by @Zingzy in #327
- fix(metadata): read to the end of the head, not a fixed prefix by @Zingzy in #328
Full Changelog: v2.3.0...v2.3.1
v2.3.0
The largest release since the FastAPI rewrite. Three things land together, plus the groundwork they share.
URL safety. A full abuse pipeline that takes a report through analysis, a verdict, and enforcement with no manual step in between. Reported destinations run through a chain of providers and get a tiered verdict stored per host, and a toxic one switches off every link that verdict covers across v2, v1 and emoji links alike, evicting caches and leaving a reason stamped on each document. Threat feeds (fishfish.gg, Google Web Risk, operator lists) also gate creation, so a known-bad destination is refused up front instead of blocked later. Creation-burst scoring and two retroactive sweeps cover what reports never reach: campaigns that never get a click, and destinations that turn bad after the links pointing at them were made. A deep tier renders a destination and classifies it. Every action is bounded by the scope of the verdict behind it, so one phishing page on a shared platform takes down its own path and not everyone else's links. The framework ships switched off.
Account deletion. Accounts can now be deleted from the product rather than by hand. DELETE /api/v1/me re-authenticates, then holds the account for a grace period during which sign-in is blocked and POST /auth/restore can cancel. After that a sweep erases everything: links, click history, custom domains, API keys, layouts, webhooks, uploads and the account itself.
Free tools API. GET /api/v1/metadata now answers anonymous callers, with its own per-IP limits, and returns the raw page title, description and favicon alongside the normalised tags. Two new endpoints join it: /api/v1/expand walks a short link's redirect chain and reports every hop with an honest safety read on the destination, and /api/v1/domain-intel returns a host's DNS, registration and TLS records.
Underneath all three: every link now stores and indexes the parsed parts of its destination, so pivoting on a domain is a lookup rather than a scan, and a scheduled task runner hosts the recurring feed syncs, sweeps and erasure passes.
What's Changed
- feat: anonymous metadata, expand, and domain-intel endpoints by @Zingzy in #320
- feat: parse and index destination URL parts by @Zingzy in #297
- feat: report-triggered safety analysis, verdicts and enforcement by @Zingzy in #299
- feat: threat feeds and the create-time policy gate by @Zingzy in #300
- feat: creation-pattern anomaly scoring by @Zingzy in #301
- feat: retroactive sweeps for feed drift and screening coverage by @Zingzy in #303
- feat(safety): block legacy v1/emoji links with a per-link audit trail by @Zingzy in #304
- feat(safety): L2 deep tier — investigation, LLM capability, evidence tools by @Zingzy in #305
- fix(safety): bound every enforcement action by verdict scope by @Zingzy in #316
- fix(safety): close the stack review findings by @Zingzy in #318
- refactor(safety): one Web Risk client behind both callers by @Zingzy in #324
- chore(caddy): sync the prod vhost from the box and allowlist /tools by @Zingzy in #323
- fix(safety): fail the Web Risk budget closed by @Zingzy in #325
- feat: self-serve account deletion with scheduled erasure by @Zingzy in #306
Full Changelog: v2.2.1...v2.3.0
v2.2.1
v2.2.0
What's Changed
- fix: document the /health response schema in OpenAPI by @Zingzy in #294
- feat: expose diagnostic headers on CORS responses by @Zingzy in #295
- feat: register sdk-ts as a first-party client slug by @Zingzy in #293
- feat: register the sdk-py first-party client slug by @Zingzy in #307
- fix(schemas): advertise epoch seconds in expire_after request schema by @Zingzy in #308
- feat(telemetry): register sdk-rust and sdk-go client slugs by @Zingzy in #309
- chore(deps): upgrade python and edge toolchain dependencies by @Zingzy in #310
- feat(telemetry): register sdk-kotlin and app-android client slugs by @Zingzy in #312
- Register the Android app's redirect URI and scopes by @Zingzy in #315
- Open Sign in with Spoo to the Android app by @Zingzy in #319
Full Changelog: v2.1.2...v2.2.0