Keep the playground's delete off a component the build ships - #109
Merged
davidmckayv merged 2 commits intoAug 21, 2026
Merged
Conversation
Hotragn
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 21, 2026 20:46
`DELETE /api/sandboxed/:name` handed the raw name to `store.remove`, which deleted from the shared `components` table with nothing checking which kind of component the name belonged to. `save` refuses a name that is not a slug and `publish` refuses a name with no draft behind it; `remove` did neither, and that asymmetry was the bug rather than the delete itself. Naming a compiled component removed its governance row, and the foreign keys took its per-Bot withholdings and its function grants with it. The withholdings are the half that fails open: a published component is available to every Bot unless a `component_exclusions` row says otherwise, so losing that row releases the component rather than hiding it, and the next catalogue announcement writes it back with `published: true`. A deliberate "not this Bot" returns as "every Bot", under an audit row saying `kind: "sandboxed"`. Gated on the governance row's `kind` rather than on the presence of a `sandboxed_components` row, because ownership is the actual question and the two differ in one case worth keeping. The two deletes are not in a transaction, so a failure between them leaves a governance row with no source; that orphan is this surface's to clear, and requiring the source row would have made it undeletable here. The route answers 404 for a name it does not own, the way publish does. Admin-only either way, so this is a footgun rather than an escalation.
Hotragn
force-pushed
the
refuse-a-name-this-surface-does-not-own
branch
from
August 21, 2026 20:55
00ffaa1 to
0f57875
Compare
Contributor
Author
|
Closing the loop on the verification caveat above: the five integration tests have now run, in CI, and passed — Rebased onto |
This was referenced Aug 21, 2026
# Conflicts: # CHANGELOG.md
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
DELETE /api/sandboxed/:namehanded the raw path parameter tostore.remove, which deleted fromcomponents— the table shared with the compiled catalogue — with nothing checking which kind ofcomponent the name belonged to.
The three mutating routes on this surface disagreed about what a name may be, and that asymmetry is
the bug rather than the delete itself:
saverefuses a name that is not a slug,publishrefuses aname with no draft behind it,
removedid neither.For a compiled component's name the
sandboxed_componentsdelete matched nothing and thecomponentsdelete succeeded, taking that component's per-Bot withholdings and its function grantswith it through
onDelete: "cascade".The withholdings are the half that fails open, and the schema says why itself — a published component
is available to every Bot unless a
component_exclusionsrow says otherwise. Losing that row does nothide the component, it releases it. Then
syncCataloguesees the component as missing on the nextannouncement from a browser and writes it back with
published: true, because that is how one thebuild ships arrives. A deliberate "not this Bot" comes back as "every Bot", under an audit row saying
kind: "sandboxed"— the one thing the deleted component was not.The function grants fail closed, so that half is a capability silently lost rather than one gained.
removenow refuses a name this surface does not own, and the route answers 404 the waypublishalready did.
Gated on the governance row's
kind, not on the presence of asandboxed_componentsrow.Ownership is the actual question, and the two answers differ in one case worth keeping: the two
deletes are not in a transaction, so a failure between them leaves a governance row with no source.
That orphan is the "catalogue disagrees with the build" state the existing comment names, it is this
surface's to clear, and requiring the source row would have made it undeletable through the only
endpoint that could. There is a test for it.
Admin-only either way, so this is a footgun rather than an escalation, and I would not put it higher
than that.
Closes #108
Where it runs
selectadded inside an existing store method.componentsrow anyreplica would; nothing is cached, held, or remembered between requests.
two administrators racing to delete the same name end where they did before: one deletes, the other
finds nothing of this kind by that name and gets a 404. The pre-existing non-transactional pair of
deletes is unchanged, and is the reason the
kindgate is written the way it is rather than asrequireRow.Boundary and audit
requireAdminstill gates the route, ahead of this.deletes, so a refused delete never reached it. There is no
component.*event for "refused adelete", and the existing
component.refusedmeans a Bot reaching for a component it does nothold, which this is not. Inventing a row for it looked like the larger change and the wrong call
for a bug fix; happy to add one if you would rather it were recorded.
surface actually owns, which is the fix — previously it claimed
kind: "sandboxed"about acompiled component.
:nameparameter used to select therow to delete on its own.
Changelog
Added under
Unreleased→Fixed: "Deleting a component in the playground could release one thebuild ships."
Proof
Five tests added to
server/tests/sandboxed-components.integration.test.ts, in a new describe block,covering: a compiled component's name is refused and its governance row survives; the withholding
that would have been released survives; the function grants survive; a name nothing was authored
under is refused; and an orphaned governance row of this surface's own kind is still deleted.
Honest limit on what I ran. These are integration tests and this machine has no PostgreSQL —
Docker Desktop is not running, and the embedded PostgreSQL this repo ships is an apt install inside
the container image rather than something reusable on a host. So I have not executed them, and CI is
the first place they will run. I would rather say that than imply a green suite I did not see.
What I did run:
The typecheck is worth more here than it usually is: Drizzle types
.values({...})against the tableschema, so a missing
notNullcolumn, a wrong column name, or a badagentTypein the fixtures wouldhave failed it rather than failing at runtime. It passes. The
agentsfixture is copied fromcomponent-store.integration.test.ts, which is an existing passing test against the same tables.Note for anyone reading the diff locally on Windows: this checkout has
core.autocrlf=true, sobiome formatreports CRLF against the working tree..gitattributesis* text=autoand thecommitted blobs are LF; the numbers above are from the LF content as committed.
What is not covered
documents the orphan it produces and asserts the orphan stays clearable. Wrapping them is a
separate change and I did not want to fold it in.
feature working, not a gap; this PR is only about names this surface does not own.