Move secure send into Tech Tools, where somebody would look for it
Some checks failed
CI / backend (push) Failing after 7m10s
CI / naming (push) Failing after 7m14s
CI / frontend (push) Failing after 7m10s
CI / migrations-mysql (push) Failing after 7m10s

9845867 gave it its own plugin because Tech Tools is browser-only and this needs
storage. That was the right reading of the code and the wrong answer for the
person using it: Tech Tools is where you go for a utility, and a tool nobody can
find is a tool nobody uses.

So Tech Tools now has a blueprint, a table and a migration chain where it had
none. Its docstring and manifest say why, because "every tool here runs in the
browser" was load-bearing documentation and is no longer true.

WHAT MOVED
  /api/securesend       -> /api/tools/secrets
  securesend.create     -> tools.secrets
  securesend0001baseline -> tools0001securesend
  PLUGIN_TABLE_OWNERS, EXPECTED_HEAD_REVISION, EXEMPT_ENDPOINTS, the universal
  profile and the api-inventory surface all follow it.

THE OLD "NO BACKEND" TEST IS NARROWED, NOT DELETED. It asserted
get_blueprint() is None, which is the sort of guard that gets removed when it
becomes inconvenient. It now asserts the blueprint serves EXACTLY the three
secret routes, with their methods - so a future tool that quietly grows an
endpoint or a table has to edit this test, and therefore make the decision on
purpose rather than by drift.

Asserting methods rather than paths also pins the detail this design turns on:
redeeming is POST-ONLY. Chat and mail clients prefetch GET urls to build
previews, and a preview that could redeem would consume a one-view secret before
its recipient opened the link. Previously that was a comment; now it fails a
test.

UNINSTALLING THE OLD PLUGIN COULD NOT BE DONE WITH THE PLUGIN TOOLING. `flask
plugin uninstall securesend` loads a plugin before uninstalling it, so a plugin
whose directory has been deleted cannot be removed by the command that manages
plugins - the registry entry had to be edited by hand. Worth fixing separately;
it is the same registry-versus-code divergence the device work hit from the
other side.

MIGRATING A CHAIN THAT HAS ALREADY RUN needs the old table and its version row
dropped first, because the relocated chain has no stamp and tries to create a
table that exists. Only relevant to anyone who installed securesend during the
hour it existed - it did reach the publication repo in 4242028 - and the fix is
DROP TABLE securesendsecrets plus DROP TABLE alembic_version_securesend before
running plugin upgrade-all.

1829 tests green. Still backend only: there are no pages yet, so nothing here is
reachable by a person.
This commit is contained in:
cproudlock
2026-08-21 14:17:57 -04:00
parent 98458672eb
commit d4b49b91b4
26 changed files with 156 additions and 281 deletions

View File

@@ -172,7 +172,7 @@
"name": "plugin-backups"
},
{
"name": "plugin-securesend"
"name": "plugin-tools"
},
{
"name": "core-docs"
@@ -18823,13 +18823,13 @@
}
}
},
"/api/securesend": {
"/api/tools/secrets": {
"post": {
"tags": [
"plugin-securesend"
"plugin-tools"
],
"summary": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never...",
"description": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents\n\n**Auth:** permission:securesend.create\n\n**Params:** body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/securesend\n```",
"description": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents\n\n**Auth:** permission:tools.secrets\n\n**Params:** body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/tools/secrets\n```",
"security": [
{
"bearerAuth": []
@@ -18873,13 +18873,13 @@
}
}
},
"/api/securesend/{token}": {
"/api/tools/secrets/{token}": {
"post": {
"tags": [
"plugin-securesend"
"plugin-tools"
],
"summary": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather...",
"description": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
"description": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
"security": [],
"responses": {
"200": {
@@ -18927,10 +18927,10 @@
},
"get": {
"tags": [
"plugin-securesend"
"plugin-tools"
],
"summary": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will...",
"description": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
"description": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
"security": [],
"responses": {
"200": {