Repository navigation
Refuse URLs with segments past the item they name - #946
Conversation
The router's structural fallback matches a path prefix, so these resolve to the parent item today: an occurrence edit page loses its date, and a document in a folder resolves to the folder.
The SDK router's structural fallback matches a path prefix, so a URL with more segments resolved to the item they sit under. Parse now rewrites the longer paths bc3 serves for an item (nested children, edit pages, upload downloads) to the item's own path, and returns nil for a structural match that leaves segments unaccounted for.
There was a problem hiding this comment.
🟢 Approved
Static review supports the focused parser fix and regression coverage, with no unresolved blocking findings.
No open issues 🎉
What changed in this PR
Fixes #944 at the CLI’s shared URL parser so URLs with extra path segments no longer silently target a parent item.
Changes:
- Normalize supported edit pages, nested child URLs, and upload download links to the intended item.
- Reject unsupported trailing paths while preserving URL classification for caller error handling.
- Add unit and end-to-end regression coverage for correct item selection and rejection.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
| File | Description |
|---|---|
internal/urlarg/urlarg.go |
Normalize supported item paths and reject incomplete structural matches. |
internal/urlarg/urlarg_trailing_test.go |
Cover trailing-path handling and refused-URL classification. |
e2e/url.bats |
Verify nested-document resolution and unsupported-path rejection. |
Review effort: Balanced
bc3 links an upload's download and preview to a version, even the
latest, as uploads/{id}/versions/{n}/download/{file} and
uploads/{id}/versions/{n}/representations/{key}. Main resolved these
to the upload; the trailing-segment check refused them, so pasting a
download link into `files show` failed. Rewrite them, and the
new-version page, to the upload. uploads/{id}/versions/{n} on its own
is not a page bc3 serves and stays refused.
|
Codex review of 9bc74dd (gpt-6.1-sol, run locally; it has no GitHub threads). One finding, fixed in eddc55d.
|
Refusing every structural match not in a list of known bc3 forms broke
real links that main resolved, such as todos/{id}/dates/edit and
comments/{id}/compositions/edit. Segments after an item's ID now name
that item unless one of them is another ID: Parse routes the URL again
without them, and refuses it only if an ID is still left over. That
replaces the /edit and versions/new rewrites.
A .json suffix is dropped before the nested-child rewrites, so
vaults/{id}/documents/{id}.json names the document. API routes that end
in a person's ID (questions/{id}/answers/by/{person},
people/{person}/out_of_office, the reports by person) or an embed's
sgid are refused, since that ID is not a recording.
|
Codex review of eddc55d (gpt-6.1-sol, run locally). Its three findings are addressed in c5da2a5, which also changes the approach. Each Codex round had found real bc3 URLs that worked on main and that this PR now refused, because refusal depended on an open-ended table of bc3 URL forms. Parse now uses one rule instead. Extra path segments with no other ID in them (
A probe of 97 URLs against main: 18 now resolve to the correct item, 15 that named a different item are refused, and none that were right on main are refused. |
vaults/{id}/documents/222.json/ resolved to the folder. The trailing
slash hid the .json from the suffix drop and the child rewrite, and the
trailing-segment trim read 222.json as a word and dropped it. The same
gap let uploads/{id}/versions/7.json/ resolve to the upload, and refused
card_tables/lists/222.json/, which main resolves.
Parse now collapses runs of slashes in the path and drops a trailing
one, as Rails does when routing, before any other rewrite. And a
segment after an ID counts as a word only if it starts with a letter,
so one that starts with digits, a percent escape or a sign, such as
222.json, 222.html or %32%32%32, is never trimmed: the URL is refused
rather than resolved to the parent. Download filenames are unaffected,
because the download rewrite consumes them first.
|
Codex review of c5da2a5 (gpt-6.1-sol, run locally).
|

Fixes #944.
What
urlarg.Parseno longer resolves a URL to an item its path runs past. Every command that takes<id|url>goes through it, so the fix applies to all of them at once.The rule is about what the extra segments name:
222.json,222.htmlor%32%32%32is never trimmed away. Examples aretodos/789/edit,todos/789/dates/edit,comments/789/compositions/editandvaults/111/documents/new. Parse routes the URL again without those segments, so a page of an API item still names it. A chat line's edit page names the line, and an occurrence's edit page keeps its date.mainalready resolved most of these, because the prefix match landed on the same item.vaults/{id}/{vaults,documents,google_documents,cloud_files,uploads}/{id},todolists/{id}/todos/{id}andquestionnaires/{id}/questions/{id}. Runs of slashes in the path become one and a trailing slash is dropped, as Rails does when routing. Then a.jsonsuffix is dropped, sovaults/111/documents/222.json/#__recording_789names comment 789 on document 222.download/{filename}link and a version'sdownload/{filename}andrepresentations/{key}links. bc3 links downloads and previews to a version even when it is the latest. The filename goes with the link, so an all-digit filename such asdownload/12345is not read as an ID.questions/1/answers/2,messages/789/boosts/5,card_tables/cards/789/steps/5anduploads/456/versions/7. Callers already refuse a nil result.IsURLstill returns true for these URLs, so callers report a URL error and do not treat the URL as a name.questions/{id}/answers/by/{person},people/{person}/out_of_office,reports/todos/assigned/{person},reports/users/progress/{person}andembeds/{sgid}. Onmain,recordings trashwith an answers-by-person URL acts on the recording that shares the person's number.people/{person}still parses as a person, because its type says so andshowhandles it./99999/buckets/123)main/schedule_entries/456/occurrences/20261022/edit20261022/vaults/111/documents/456/chats/789/lines/111/edit/todos/789/dates/edit/vaults/111/children/222/questions/111/answers/by/789Why
The SDK router tries its API routes first, and those match the whole path. When none matches, it falls back to structural patterns that match only the start of the path and drop the rest. The fault is in the SDK, filed as basecamp/basecamp-sdk#1036. This PR adds the check at the CLI's single entry point, so the CLI does not wait for an SDK release.
An earlier version of this PR refused every structural match not in a list of known bc3 forms. Review kept finding real bc3 links that
mainresolved and that list refused, such asdates/editandcompositions/edit. The list could not be finished. Whether the extra segments hold an ID can be checked on any URL.On
main, against an unreachable server:basecamp recordings trash <chat line edit URL>sendsPUT /recordings/789/status/trashed.json, which trashes the whole chat.basecamp spotlight <occurrence edit URL>spotlights the whole recurring series.On this branch, the first command trashes line 111 and the second is refused with the existing occurrence message.
Not included
basecamp url parsedoes not print the occurrence date. That was already true for plain occurrence URLs.ExtractIDas the raw string. The command then fails with its existing "Invalid ID" error, not a message that names the URL.webhooks/{id},templates/{id}andcategories/{id}, parse as before. A command that ignores the type can still act on the recording that shares the number, as onmain.my/profile/edit, still parses as the account, as onmain.Testing
internal/urlarg/urlarg_trailing_test.gohas 56 table cases that compare the wholeParsedresult. Againstmain's parser, 35 fail. The 21 that pass are forms where the prefix match gives the right item, and they check that the fix does not refuse them. Against eddc55d, 17 fail. Against c5da2a5, 10 fail: a trailing slash after.json, a doubled slash, and a nested ID with a suffix, a percent escape or a sign..jsondrop, the person and sgid check, the second routing without the trailing words, the slash collapse (6) or the letter-start rule for a trimmed segment (4).e2e/url.bats:url parsenames a document in a folder and refusesmessages/789/boosts/5.Parseonmainand on this branch. 54 results changed. In 30 the result went from the parent, an empty result or nil to the item itself, the child, line, person or Ping, or the occurrence date came back. In 24 a URL that named a different item now returns nil. None went from the right item to nil, and none resolves to an item other than the one the URL names. The other 87 did not change.bin/cipassesSummary by cubic
Fixes #944 so
urlarg.Parseno longer resolves a URL to the parent item when its path runs past the item. Every command that takes<id|url>goes throughParse, so the chat line edit URL now trashes the line, not the whole chat, and an occurrence edit URL is refused rather than spotlighting the whole series.Behavior
todos/789/edit,todos/789/dates/edit,comments/789/compositions/edit, and unknown pages all still name the item. An occurrence's edit page keeps its date. A segment counts as a word only if it starts with a letter: one that starts with digits, a percent escape, or a sign (222.json,222.html,%32%32%32) is treated as an ID and refused rather than resolved to the parent.vaults/{id}/{vaults,documents,google_documents,cloud_files,uploads}/{id},todolists/{id}/todos/{id}, andquestionnaires/{id}/questions/{id}. A.jsonsuffix is dropped before rewriting.uploads/{id}/download/{filename},uploads/{id}/versions/{n}/download/{filename},uploads/{id}/versions/{n}/representations/{key}, anduploads/{id}/versions/new. A bareuploads/{id}/versions/{n}is not a page bc3 serves and stays refused.IsURLstill returns true, callers report a URL error instead of treating the URL as a name.The root cause is the SDK router's structural fallback matching only a path prefix (basecamp/basecamp-sdk#1036); this PR adds the check at the CLI's single entry point so it does not wait for an SDK release.
Written for commit 960bb08. Summary will update on new commits.