Repository navigation
fix(callback-url): single-use, conversation-bound callback tokens with 7-day TTL (#194) - #256
Conversation
…h 7-day TTL (#194) Port the callback-token half of upstream b7c9316b (vercel/chat#875, chat@4.40.0) and the button copy from 4a0b5c0c (vercel/chat#895): - store {actionId, url, originalValue?, scope: {id, type}}; TTL 30d -> 7d - resolve_callback_url takes a CallbackContext, locks the token for 10s, validates strictly, matches actionId + scope, deletes, releases in finally - thread/channel posts bind to their own scope; the post_ephemeral DM fallback mints after open_dm, bound to the DM channel; channel SentMessage.edit binds to the message's thread - Chat POSTs the stored actionId - token swap keeps every button field except callback_url Breaking for in-flight tokens: legacy records stop resolving, repeat clicks no longer POST. Closes #194
|
Warning Review limit reachedNext included review available in 20 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (12)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
#194) Python-specific hardening (divergence from upstream, documented in docs/UPSTREAM_SYNC.md): after deleting a matched record, resolve_callback_url confirms with extend_lock that its 10s lease never lapsed, so a stalled state call cannot let a second click also resolve and POST. Also records the chained channel-edit gap owned by #195.
…read was reported (#194) Teams and Google Chat post_channel_message return the channel id as the thread id (upstream too), and a chained edit drops the override until #195, so upstream's {thread_id, "thread"} edit scope can never match a click. Bind those edits to the channel, as the original post did. Documented in the non-parity table; regression tests cover a real-Teams-id round trip and chained edits.
…to the channel (#194) The reported thread id for a channel post never matches a click on Slack today (synthetic slack:C…:) nor on Slack DMs after #209 (DM clicks carry no ts), so the conditional thread scope left edited buttons unable to POST. Channel scope matches every click on the message and is no broader than the original post's scope. Also: ChannelImpl.post_ephemeral no-delivery-path test (mints nothing), single-token assertion on the DM-fallback test, and upstream-issue notes on both #194 divergences. The lease fence landed in d856645 under a fix() prefix; it is a divergence: diverge(callback-url) — see docs/UPSTREAM_SYNC.md.
# Conflicts: # docs/UPSTREAM_SYNC.md # scripts/fidelity_target.json
|
Merge gate: CI green on 84bfd56 (Lint & Type Check, test (3.12), test (3.13), CodeQL / Analyze (python, actions)); local Codex review (gpt-6-astra, xhigh, --base origin/main) on 2b50c8f: "No actionable regressions found. The full test suite passed (5,929 passed, 24 skipped), and lint and type checks passed."; 5 astra rounds (rounds 1-3 findings fixed, round 4 and the converge re-review clean). 84bfd56 only merges origin/main (#221, #250): no conflicts, Teams-only files with no callback-token interaction, so no fresh review was needed. Local full validation passed: 6023 passed, strict fidelity OK, pyrefly 0 errors, fidelity_target unchanged (missing 250 -> 250). CodeRabbit is rate-limited and no other bot findings are open. Merging with --admin (Protect Main requires a code-owner approval). |
Summary
Ports the callback-token half of upstream's conversation-boundary hardening. Callback-URL button tokens (
value = "__cb:<token>") are now:actionId) and to the conversation they were posted in (scope: {id, type});The callback POST now reports the stored
actionId. The token swap also keeps every button field exceptcallback_url.Breaking for in-flight tokens. See Consumer impact below.
Upstream commits mapped
b7c9316bfix(chat): tighten conversation boundaries (vercel/chat#875, chat@4.40.0)actionId+ scope match, delete,release_lockinfinally, thread / channel / DM-fallback / channel-edit scopes,Chatpasses{action_id, channel_id, thread_id}and POSTs the storedactionId. The rest of that commit is in #195, #229/#230 and #235.4a0b5c0cfeat(cards): button tooltips and card width hint (vercel/chat#895, chat@4.40.0){k: v for k, v in el.items() if k != "callback_url"}plus the encodedvalue. Thetooltip/widthfields belong to #202.Changes
src/chat_sdk/callback_url.py:CALLBACK_TTL_MSis now 7 days. New constantCALLBACK_LOCK_TTL_MS = 10_000.CallbackScope(id, type)andCallbackContext(action_id, channel_id=None, thread_id=None).ResolvedCallbackgains keyword-onlyaction_idandscope.process_card_callback_urls(card, state, scope)takes a required scope.resolve_callback_url(token, state, context=None): returnsNoneif the lock is held. Otherwise it gets the record and validates it withisinstance, never truthiness. It then matchesactionIdand the scope id, deletes the record, and releases the lock infinally. ANonecontext never matches, and a mismatch leaves the record in place.thread.py/channel.py:_process_callback_urls(postable, scope=None)defaults to{thread.id, "thread"}for a thread and{channel.id, "channel"}for a channel.post_ephemeralmints only on the path that delivers. The native path uses the default scope. The DM fallback mints afteropen_dm, bound to{adapter.channel_id_from_thread_id(dm_thread_id), "channel"}. When neither path runs, no token is minted.SentMessage.editbinds to{channel.id, "channel"}(divergence, below).chat.py_handle_action_eventbuilds theCallbackContext(channel_idcomes fromchannel_id_from_thread_id(event.thread_id)when a thread id is present) and sends the storedactionIdin the POST.Tests
Ported from upstream (chat@4.41.1):
callback-url.test.ts:Button(tooltip=)from [4.41/C10] Cards & modals core: Chart, Table options, Button tooltip, Card width, DateInput/NumberInput, select change events #202).processCardCallbackUrlstests now pass a scope, and "resolves stored callback with URL and original value" also asserts the scope and that the record was deleted.thread.test.ts: "should bind fallback DM callbacks to the DM channel" (slack:DU456). "should encode callbackUrl when posting a card" now asserts the thread scope.channel.test.ts: "should bind fallback DM callbacks to the DM channel" (slack:DU1), ported explicitly. "should encode callbackUrl tokens when posting a card" now asserts{id: "slack:C123", type: "channel"}.chat.test.ts: thetesttoken123,tok999,tok555andbad-tokenfixtures gainactionIdandscope.tests/integration/test_replay_callback_url.pyis updated the same way (slack:C00FAKECHAN1, as upstream).Python-specific:
TestResolveCallbackUrlValidation: parametrized malformed records are rejected and not deleted. An empty-stringactionIdpasses, as upstream. The snake_caseoriginal_valuekey is no longer read. ANonecontext never matches.TestResolveCallbackUrlLocking:asyncio.gatherof two resolves onMemoryStateAdapteryields exactly one result.getyields inside the critical section, so the test fails without the lock.Nonewithout reading.release_lockis awaited whengetraises.TestProcessCardCallbackUrlsStoredRecord: pins the exact camelCase record and the 7-day TTL.originalValueis omitted, neverNone.TestActionsCallbackTokenBinding(test_chat_faithful.py): the resolver receives the click context and the POST carries the storedactionId; a repeat click dispatches the raw value with no second POST; a click from another thread neither resolves nor consumes the token.post_ephemeralwith no native path and nofallback_to_dmnever awaitsstate.set._handle_block_actions(channel and DM, syntheticslack:C…:post id and post-[4.41/SL4] Slack inbound normalization: self-mention decode, content is_mention, mrkdwn fixes, bot author ids, socket retries #209slack:C…:<ts>post id).ChannelImpl.post_ephemeralwith no delivery path mints nothing (Channel twin of the Thread test); the channel DM-fallback test asserts exactly one token.Fidelity
chat@4.31.0): 733/733.--report-target,chat@4.41.1):origin/main(256 missing on main),scripts/fidelity_target.jsonwas regenerated rather than hand-merged: 250 missing (main 256 -> 250, -6).Divergences
Two behavioral divergences, which is the budget from
docs/UPSTREAM_SYNC.md. Both are in the non-parity table.Channel edit callback scope (commit
diverge(channel), widened in the convergence pass). Upstream binds a channelSentMessage.editto{threadId, "thread"}, wherethreadIdis the id the adapter reported for the post. Python binds every channel edit to{channel.id, "channel"}, the scope the original post used. The reported thread id often never equals a click's thread id: Teams and Google Chat report the channel id (upstream too), Python Slack reports the syntheticslack:C…:until #209 while clicks carry the message ts, a Slack DM click carries no ts even after #209 makes the post report one, and a chained edit drops the override until #195. Every click on the message derives the channel id, so edited buttons POST regardless of #209's merge order.test_edited_slack_channel_card_resolves_for_the_real_click, 4 cases), the real-Teams-id round trip (test_edited_teams_channel_card_resolves_for_a_click_in_that_channel) andtest_chained_edit_keeps_callback_tokens_resolvable.The lease fence (Astra round 2). After deleting a matched record,
resolve_callback_urlcallsextend_lock(lock, CALLBACK_LOCK_TTL_MS). If that fails, the 10 s lease lapsed mid-consume, and the call returnsNone(fail closed). Upstream returns the record regardless, so agetordeletethat stalls past the lease lets a second click take the expired lock, resolve the same token, and POST again.extend_lockchecks token ownership and expiry in every backend, andChatalready relies on it for lock heartbeats.test_lost_lease_fails_closed_instead_of_double_consuming.d856645under afix(callback-url)prefix; relabeled by the emptydiverge(callback-url)commit (no force-push). The squash title/body should carry bothdiverge(channel)anddiverge(callback-url). Upstream issue: to be filed (a stalled consume past the lease double-POSTs).Two Python-surface notes:
test_handles_legacy_string_formatis kept, not deleted (the issue said to delete it). "handles legacy string format" is still incallback-url.test.tsat the strict pin (chat@4.31.0), so deleting the def fails--strict(732/733). It now asserts the new behavior, where the rejection test does not: a legacy string record resolves nothing even with a context that would match, and it is not deleted. It can be folded into the rejection test when the pin moves ([4.41/C11] Bump fidelity pin + UPSTREAM_PARITY to chat@4.41.1, record non-parity rows, cut 0.4.41 #203).ResolvedCallback.action_id/scopeare keyword-only, so existing positionalResolvedCallback(url, original_value)construction still binds the same fields.docs/UPSTREAM_SYNC.mdgains a "Callback-URL tokens (chat@4.40, #194)" section. It records the legacy-record rejection and the droppedoriginal_valuefallback, which no Python release ever wrote, and it has the per-adapterchannel_id_from_thread_idcheck.Verify-first results
Downstream: chinchill-api pins
v0.4.31.1. A grep of its Python sources findscallback_urlonly in OAuth redirect code (mcp_servers.py,integrations.py,integration_oauth_service.py). There is no cardButton(callback_url=),resolve_callback_urlor__cb:use, so nothing depends on repeat clicks or on the 30-day TTL.channel_id_from_thread_idvsChannelImpl.id:thread.channelderives its id with the same function in every adapter (derive_channel_id).chat.channel(id)matches wheneveridis canonical:slack:C…discord:{guild}:{channel}gchat:spaces/…github:owner/repolinear:{issueId}telegram:{chatId};messageid=strippedNo adapter diverges.
Known thread-scope click-id mismatches (Astra rounds 1–3)
The adapter reports a click
thread_idthat differs from the thread id the card was posted under. The click still runs handlers with the raw__cb:value, but nothing is POSTed. Recorded indocs/UPSTREAM_SYNC.mdand CHANGELOG:thread.posted into a Google Chat DM thread (…:dm). This is upstream behavior at chat@4.41.1, becausehandleCardClickomits the:dmsuffix. Changing the Google Chat click thread id would be an adapter divergence that changes handler thread ids, so it is left out of this core PR. Thepost_ephemeralDM fallback is unaffected, because it binds to the DM channel.The former Slack channel-edit gap (synthetic
slack:C…:post id, until #209) is closed by the widened channel-edit divergence above, which also covers Slack DMs after #209, Teams, Google Chat and chained edits.Consumer impact
Breaking for
callback_urlusers (CHANGELOG entry under "Unreleased (4.41 wave)", marked Breaking):__cb:…value and nothing is POSTed.actionIdis now the minted button's id.There is no impact on Slack/Teams streaming, and none on chinchill, which does not use card callback URLs.
Validation
ruff check,ruff format --check,audit_test_quality.py(0 hard failures),verify_test_fidelity.py --check-docsand--strictat 4.31.0 (733/733) all pass, andpytestreports 5929 passed, 24 skipped (after mergingorigin/main).pyrefly checkreports 0 errors.Merge gate
Review findings (2 reviewers, 5 items):
slack:C…card buttons never POST until [4.41/SL4] Slack inbound normalization: self-mention decode, content is_mention, mrkdwn fixes, bot author ids, socket retries #209). ChannelSentMessage.editnow always binds to the channel. Failing-first:test_edited_slack_channel_card_resolves_for_the_real_click(realSlackAdapterids +_handle_block_actions) fails on the previous HEAD.post_ephemeralno-delivery-path test. Addedtest_post_ephemeral_without_delivery_path_mints_no_callback_tokentotest_channel_faithful.pyplus a one-token assertion in the DM-fallback test; both kill the pre-mint mutation.fix(...)prefix. An emptydiverge(callback-url)commit relabelsd856645; the squash message must carry bothdiverge(...)labels. Non-parity rows now say the upstream issues are to be filed (not filed from this automation).Main merge:
origin/mainmerged;docs/UPSTREAM_SYNC.mdconflict resolved (both sections kept; #202's "interim callback-URL gap (until #194)" bullet updated since this PR closes it), CHANGELOG #202 tooltip note updated,test_callback_url.pynow uses the realButton(tooltip=),fidelity_target.jsonregenerated (256 -> 250).gpt-6-astra: 1 round on
2b50c8f: "No actionable regressions found." (clean).Bots: CodeRabbit skipped (draft, then rate limited); no gemini or inline review comments.
CI on
2b50c8f: Tests 3.12/3.13, Lint & Type Check (ruff, audit, pin drift, strict fidelity, pyrefly), CodeQL: all green.Closes #194
Part of #184