Wizard PUBLISH NOW creates an election nobody can own¶
Not posted yet. Slack first, per the README ground rule — an inverted ownership guard is sharper than a UI or copy defect, and owner_id: null was written deliberately at Wizard.tsx:119, so "is this deliberate?" is the honest framing rather than a pose. Rename this file to <n>-wizard-publish-now-orphans-election.md once it has a number.
Live repro: bettervoting.com/jd78xd — created 2026-08-03 through the production wizard, signed out, one click on PUBLISH NOW. Frozen at reference/frozen/jd78xd-snapshot.json.
Detail page: reference/creating-an-election.md.
Slack draft¶
Question on the create wizard — is this deliberate? On
main,Wizard.tsx:119(the PUBLISH NOW branch) passesowner_id: null, butonAddElectionat:83only assigns the temp idif (election.owner_id != null), whileclaim_key_hashis set unconditionally just below. Net effect on production: a signed-out PUBLISH NOW election gets a claim key but no owner, sotempUserAuthinelections.controllers.tscan't be satisfied —owner_id == cookies.temp_idcomparesnullto av-…id — and the election can never be claimed or closed. It staysopenforever. Repro isjd78xd(mine, created today). The See more options path is fine, sincemakeDefaultElection()leavesowner_id: '0'. Happy to open an issue if it's not intended.
Housekeeping¶
jd78xdis itself an orphan — that's the finding, so it can't be cleaned up. It staysstate: openwithvoter_access: open, meaning anyone reading the issue can cast a ballot in it. Snapshot frozen at 0 ballots,2026-08-04T02:09:49Z. If the issue cites tallies later, re-freeze.- Nothing to claim, so no claim key to withhold. Unlike
43jp39in #1470, there is no ownership to protect here — the claim key in that browser session is worthless, which is the whole point. - One thing held back from the public page. See the note at the bottom of this file; it goes to Arend, not into
reference/.
What to post¶
Everything below the line is the issue body as drafted.
Title¶
Wizard "Publish Now" creates an election with owner_id: null — it can never be claimed, administered, or closed
Body¶
Summary¶
An election created through the wizard's PUBLISH NOW button while signed out is permanently unownable. It is written with a valid claim_key_hash but with owner_id: null, and the guest-ownership check requires owner_id == cookies.temp_id. null can never equal the browser's v-… temp id, so the owner role is never granted — which also means canClaimElection is never granted, so signing in later cannot rescue it.
The practical result is an election that is open forever: it accepts ballots indefinitely and no one — including its creator — can close, edit, finalize, or delete it. Only a system_admin can intervene.
The longer See more options path is unaffected.
Reproduction¶
- Open https://bettervoting.com/new_election signed out.
- Fill in a title and two candidates, pick any method, click through to the Publish? dialog.
- Click PUBLISH NOW.
- In the same browser, with the
temp_idand<id>_claim_keycookies that the wizard just set:
await (await fetch('/API/Election/<id>', {credentials:'include'})).json()
// → voterAuth: { authorized_voter: true, has_voted: false, roles: [], permissions: [] }
No owner role, well inside the 10-hour window. The cause is visible anonymously:
curl -s https://bettervoting.com/API/Election/jd78xd \
| jq '.election | {owner_id, has_claim_hash: (.claim_key_hash != null), state}'
# → { "owner_id": null, "has_claim_hash": true, "state": "open" }
Live example: jd78xd, created 2026-08-03 by exactly these steps.
Control: the same wizard, the other button¶
rqq2pw was created minutes later in the same browser and the same session, identical up to the Publish? dialog, then taking SEE MORE OPTIONS instead — Restricted: No, template one person, one vote.
PUBLISH NOW (jd78xd) |
SEE MORE OPTIONS (rqq2pw) |
|
|---|---|---|
owner_id |
null |
v-dbg9w2gt — equal to the temp_id cookie |
claim_key_hash |
present | present |
state on creation |
open |
draft |
voterAuth.roles |
[] |
["owner"], with 23 permissions |
| Owner-only call | setOpenState → 401 Does not have permission |
DELETE → 200 Election Deleted |
Both rows are executed results, not readings. The control was deleted by its creator immediately afterwards — which is the point: that is the capability the quick path never grants. The orphan cannot be closed, deleted, or claimed, by anyone.
Root cause¶
packages/frontend/src/components/ElectionForm/Wizard/Wizard.tsx.
The PUBLISH NOW branch passes owner_id: null explicitly:
// :119, in onNext()
onAddElection({...updatedElection, owner_id: null, state: 'finalized',
settings: setVoterAuthenticationMode(updatedElection.settings, 'open_unique_cookie')}, '/')
and onAddElection assigns the temp id only when owner_id is already non-null:
// :83-87
if (election.owner_id != null){
election.owner_id = authSession.isLoggedIn() ? authSession.getIdField('sub') : submitTempID;
}
const claimKey = crypto.randomUUID();
election.claim_key_hash = hashString(claimKey);
The guard is false, so the temp id is never assigned — while claim_key_hash is set unconditionally two lines below, outside the guard. That produces the exact shape seen in production: hash present, owner absent.
As written, the condition only assigns an owner when one already exists, which looks inverted. It is also what spares the other path: makeDefaultElection() sets owner_id: '0' (:34), so the See more options branch — which calls setPage(1) without the owner_id: null override — passes the guard and receives a real temp id.
Downstream, elections.controllers.ts:
const ownerIsTempUser = !req.election.owner_id || req.election.owner_id.startsWith('v-');
const tempUserAuth =
ownerIsTempUser &&
req.election.owner_id == req.cookies.temp_id &&
...
null passes ownerIsTempUser via the !owner_id disjunct, then fails on owner_id == cookies.temp_id.
Scope¶
- Affects every election created by PUBLISH NOW while signed out — confirmed on production.
- Signed-in creators are predicted to hit it too, and this has not been run.
:119passesowner_id: nullunconditionally, which makes theauthSession.isLoggedIn()branch at:84unreachable on this path whatever the session. If that reading is right, a logged-in user clicking PUBLISH NOW also gets an unownable election — it would not even appear under Elections you manage. Flagging rather than asserting. - Unaffected: the See more options path, and anything created through the API with an
owner_idset. - Severity is durability, not data loss. Ballots tally correctly; the election simply cannot be administered, and cannot be stopped from accepting more.
Suggested fix¶
Either drop the owner_id: null override at :119, or invert the guard at :83 so it assigns when unset:
if (election.owner_id == null){
election.owner_id = authSession.isLoggedIn() ? authSession.getIdField('sub') : submitTempID;
}
The second is the smaller change and makes makeDefaultElection()'s owner_id: '0' sentinel unnecessary. Either way, a regression test asserting owner_id is non-null after a quick-publish would catch it.
Existing orphans can't be repaired by this fix — owner_id is immutable without the owner role, so they need a system_admin sweep or a migration.
Provenance¶
| Claim | How established |
|---|---|
owner_id: null, claim_key_hash present, state: open on a wizard-created election |
executed — anonymous curl against jd78xd, frozen snapshot |
roles: [] with both guest cookies present, inside the 10 h window |
executed — same-origin fetch with credentials: 'include' from the creating browser session |
| Cookies were present and not HttpOnly | executed — read back from document.cookie |
Wizard.tsx:119 passes owner_id: null |
read from source, main via GitHub API |
Wizard.tsx:83-87 guard, claim_key_hash set outside it |
read from source |
makeDefaultElection() sets owner_id: '0' (:34) |
read from source |
tempUserAuth fails on the equality, not the v- prefix |
read from source, elections.controllers.ts |
| The See more options path is unaffected | executed — rqq2pw, same browser and session, 2026-08-03. owner_id: v-dbg9w2gt equal to the temp_id cookie, state: draft, roles: ["owner"], and an owner-only DELETE returned 200 Election Deleted |
| The orphan genuinely cannot be administered | executed — owner-only setOpenState on jd78xd, with both guest cookies, returned 401 Does not have permission (7f7f3603) |
| Signed-in creators are not spared either | read from source, not executed. :119 passes owner_id: null unconditionally, so the authSession.isLoggedIn() branch at :84 is unreachable on this path regardless of session. Stated as a prediction in the issue body; needs one signed-in run |
| Description field discarded by the wizard | retracted. Wizard.tsx:110-116 maps it through on the same object as the title. Corroborated during the control run: the same synthetic fill left the title unset in React state ("Title required" fired on a visibly populated field) and real keystrokes fixed it. Harness artifact. Do not include |
The runs were browser-automated. That matters for input-binding claims — hence the retraction, which the control run independently confirmed — but not for the ownership finding, which is a property of the request the wizard constructs, corroborated by source on both ends and by a same-session control that behaved correctly.
One more item, verbally¶
There is a second observation about the same tempUserAuth expression that belongs in the Slack conversation and not in this repo — it concerns the comparison semantics, and a correct patch should address it alongside :119. It is deliberately unwritten here, for the same reason claim keys aren't here: this repo is public and the item hasn't been raised yet.
It is not stored in any file. /Volumes/T7/Voting/BetterVoting/bv-security-findings-unreported.md — the location the 2026-07-30 handoff uses for unreported items — was not mounted when this was written, so nothing was appended to it. The item was handed to Adam directly in the session it came from. If it hasn't reached Slack or that file, it exists nowhere; re-derive it by reading the four conditions of tempUserAuth in elections.controllers.ts and asking what each one does when owner_id is null.