Adds User Authenticated Google Chat support to the Google gatekeeper (#560)
* refactor(gatekeeper-google): share the approval-queue plumbing
SharedApprovalQueue, the reference-holding RpcTarget base, and RpcCursor
were defined in gmail.ts and copied verbatim into chat.ts. Move them to
shared-approval-queue.ts so both import one implementation.
* Add user-authenticated Google Chat gatekeeper
Two grantable resources on the Google gatekeeper: the whole Chat account
(discovery and cross-space search; not shareable, since it spans direct
messages) and one conversation (shareable to collaborators whose own
Google account can open the same space). Sessions read messages,
members, reactions, and attachments, and queue sends, edits, deletes,
and reactions through the approval queue, with reads simulating queued
writes until they are applied. Attachment bytes are held in the Durable
Object and only uploaded on apply. Everything runs as the connected
user: no chat.bot, app, admin, or import surfaces, and app-authored
private messages are omitted on every path. The binding pins the
account's stable subject so a reconnect to a different Google account
refuses to act. Four user-auth scopes; the release manifest golden
gains the DO migration.
* test(gatekeeper-google): pin the Chat gatekeeper's core behaviors in workerd
Three behavior tests against the real Durable Object, approval queue, and
cursor plumbing — the properties the Node suites cannot see:
- Attachment bytes stay local until applyAction: nothing reaches Google
at submit, and one apply performs the upload and the idempotent create.
- A denied listMessages page is re-offered on retry with the queued
message still on it (newest-first, the order a stale first-page flag
silently dropped it in; verified to fail against the old behavior).
- A binding whose credentials follow a reconnect to a different Google
account refuses to act instead of impersonating the original one.
The TestHooks harness gains a minimal Chat surface mirroring Gmail's, and
TestApprovalQueue can deny one observation by title.
* feat(gatekeeper-google): add first-class Chat thread capabilities
* fix(gatekeeper-google): harden Chat actions and resolve DM names on demand
* refactor(gatekeeper-google): return entries from Chat lookups and scope the space capability
Address the API-shape review: getSpace/findDirectMessage/getThread/getMessage/
getRootMessage/post/reply/startThread return the same {info, capability} entries
the listings use, so a lookup is never followed by a second read. ChatSpace gains
getCurrentUser and a search limited to its conversation, listThreads throws where
threads are unsupported like the other thread methods, getAttachment(id) replaces
listAttachments(), and threadId is omitted in conversations that do not thread.
Also blank text on deleted tombstones and apply queued edits to one message in
submission order, since manual approval can run them out of order.
* fix(gatekeeper-google): keep the threading lookup out of the Chat create path
A spaces.get failing after Google accepted the post must not read as a failed
write; the created message's threadId is only used inside applyAction.
* fix(gatekeeper-google): close the Chat apply/reject race and harden lookups
applyAction yields while resolving the account, and the overseer lets a
rejectAction land in that window; the write helper now refuses once the action
is gone, so nothing reaches Chat that the queue records as rejected.
Also: reject bare dot-segment ids that the fetch layer would collapse into a
different endpoint; run a search page's per-space threading lookups together
and let one failing space cost only its thread IDs; don't negative-cache a DM
name lookup that the resolver's own deadline cut short; list the Chat and
People APIs in the deploy-wizard setup steps.
* simplify(gatekeeper-google): drop the Chat People fallback, undo-receipt staging, and apply-time thread rechecks
- DM names come from the DM's own members.list, which Chat populates for the
people the account talks to. The People API fallback, its deadline/cache
machinery, the observed-name tracker and verifyChatNames go; a shared space
binding is plain strategy B (can the collaborator open the space).
- Undo receipts are written after a write succeeds, as elsewhere. Sends stay
idempotent via requestId and reactions re-find their own state, so a lost
response costs at most an edit's revert baseline, not a second state machine.
- A write is scoped when queued; apply and undo no longer re-verify a thread
boundary that Chat cannot move a message across.
- threadId is passed through as Chat returns it, documented as meaningful only
where supportsThreads is true; the per-space threading memo goes.
* simplify(gatekeeper-google): drop formattedText from Chat messages
Agents work from text; the markup body only brought along rules for clearing
it on overlaid edits and tombstones.
* fix(gatekeeper-google): thread DMs and group chats, split search keywords, add mentionsMe
Thread support now follows spaceThreadingState alone, so threaded direct
messages and group chats can be threaded. Search text is split into words
and quoted phrases, each its own AND term; spaceNameContains is removed;
mentionsMe filters for messages mentioning the caller.
* refactor(gatekeeper-google): revise the Chat agent API shapes
- Messages list who they @mention; deleted messages are never returned.
- Memberships are a user/group union with the assistant-manager role;
DM metadata identifies its peer.
- GoogleChatSession becomes ChatSession; listSpaces takes spaceTypes.
- getSpace accepts an id, a bare id, or a chat.google.com link.
- Conversation history defaults to newest first.
- edit() refuses someone else's message at call time; a pending
message lists no reactions.
* feat(gatekeeper-google): continue messages as threads
ChatMessage.getThread() replaces ChatSpace.startThread(): post a message,
then continue it as a thread. Thread discovery and metadata always carry
the thread's first message, fetched when it falls outside the scanned
page or window. ChatThread gains getCurrentUser().
* feat(gatekeeper-google): grantable Chat thread resource
https://chat.google.com/room/:spaceId/:threadId binds one thread as a
ChatThread session. Its configurator mints the canonical URL from a
pasted Copy link (room or dm, with or without a message segment) or a
spaces/{space}/threads/{thread} name. Observers are verified against the
thread's conversation, since Google's access control stops there.
* docs(gatekeeper-google): Chat agent contract
Rewrite the agent-facing comments in chat-types.d.ts for the revised API,
and bring docs/google-chat-capabilities.md in line with it.
* fix(gatekeeper-google): show Chat approvals exactly and apply them safely
Message text reaches the approver as verbatim fields rather than a Markdown
preview that hid HTML and truncated, and provider names are flattened with
plainInline. A reject is refused while its apply is in flight, and queued
reactions, like edits, apply in submission order.
* feat(gatekeeper-google): name Chat DM peers through the People API
Chat may omit display names under user auth, so a nameless DM peer is looked
up in the People API, and DM send approvals name their recipient. The Chat
contract now describes mention read-back, word-prefix space search and the
cross-conversation filter requirement accurately.
* docs(gatekeeper-google): Chat app and People API setup
Google refuses user-authenticated Chat writes until the project configures a
Chat app, so the deploy wizard and README now say so, and they list the People
API used for DM names. worker-configuration.d.ts gains the Chat Durable Object.
* fix(gatekeeper-google): tighten Chat undo, scope and link handling
- Undoing an edit refuses when the message changed since, instead of
overwriting the later text; the revert record keeps the text Chat stored.
- Chat-in-Gmail #chat/ links resolve like chat.google.com ones; the space
configurator drops prefill shapes it can never receive.
- DM connections are titled after the peer, and a failed DM lookup falls back
to the plain label rather than blocking a post or a connection.
- Scope checks run before any read in queueChatMessage, and apply-time checks
cover the bound thread.
- Rejecting an action asks for a restart only when a later action shares its
conversation, and a reply whose root was rejected says so.
- getGoogleAccountProfile no longer repeats a 401 for a fixed token.
* feat(gatekeeper-google): name unnamed group chats after their members
describeConversation (was describeDirectMessage) now also labels an unnamed
group chat with its first three other participants, e.g. "Alice, Bob, and 2
more", in getMetadata, send approvals and connection titles. Nameless people
are resolved in one People people:batchGet call, matched by
requestedResourceName so a contact-linked alias still counts.
ChatSpace.getMetadata no longer fails when the member lookup does.
* fix(gatekeeper-google): settle uncertain Chat writes, refuse stale edits, keep read state owner-only
- An action whose write may have reached Google is marked before the write,
and rejectAction refuses it until a retry (idempotent via requestId, or a
no-op re-check for edits and reactions) settles it; a failure before any
write stays rejectable.
- Edits record the text they replace; apply refuses when Chat's text has
since changed, instead of overwriting a hand edit, and undo restores that
recorded text.
- unreadOnly moves to account-wide search. Read state is the owner's, and a
space binding can be shared; RPC validation passes undeclared fields
through, so the space search clears it explicitly.
- A thread binding's describe() reads up to three pages for its first
visible message rather than failing on a page of hidden ones.
* fix(gatekeeper-google): skip the 401 replay when a token cannot refresh
fetchWithAuthRetry now replays a 401 only when the refreshed token differs
from the rejected one, as its 403 branch already does. That lets
getGoogleAccountProfile go back to one request path, so a fixed token keeps
the timeout and 429/5xx backoff.
* simplify(gatekeeper-google): tidy Chat naming, search and thread titles
- Space search copies only its declared filters instead of clearing unreadOnly by name.
- describeConversation is best-effort itself, and exports needsDescription for describe().
- The thread title reuses CursorPager with a small page size.
- addReaction returns early, like removeReaction.
* fix(gatekeeper-google): keep a definitively refused Chat write rejectable
Every provider write now runs through ChatStore.attemptWrite, which marks the
action uncertain first and clears the mark when Chat refuses the first write
with a 4xx other than 408/429: that refusal proves nothing landed, so the
action no longer wedges unrejectable. A refusal on a later attempt keeps the
mark, since a send has no read that could show whether an earlier attempt
posted.
* fix(gatekeeper-google): rebase queued Chat edits, narrow scopes, check member-list access
- When a send or edit applies, later edits queued against its submitted text
now expect the text Chat stored, so a committed <users/...> mention read
back as @Name no longer trips the external-edit conflict.
- chat.users.readstate.readonly is requested for the account resource only;
conversation and thread grants no longer need it.
- A space binding's observer must also be able to list the space's members,
since a space can restrict its member list to managers and the binding
lists members; thread bindings expose no members and keep the space check.
- Restore attemptWrite's clear on a definitive first-write refusal.
* docs(gatekeeper-google): note live-verified DM thread replies
* fix(gatekeeper-google): name Chat picker conversations after their participants
The conversation picker titled every DM "Direct message", matched queries
against conversation IDs, and showed a 1/1/1970 activity date where Google
reports an unset time as the epoch.
- Name DMs and unnamed group chats as getMetadata does, cached per picker
and capped at 200, since each costs a members.list read against the
project-wide quota. People lookups are batched across conversations.
- Match by name only and drop the ID tail.
- Treat epoch timestamps as absent.
* fix(gatekeeper-google): share in-flight Chat picker naming across searches
The picker loads on focus and again for a search typed during that load;
caching only finished names made both describe the same conversations.
* fix(gatekeeper-google): find Chat picker DMs by email instead of naming them
Reverts c1494e072 and 43fd6fc83. Naming every listed DM cost a
members.list read each against the project-wide quota, and a typed
query named conversations in Google's list order, so later DMs stayed
unfindable anyway.
- An email address query resolves the DM with that person through
spaces.findDirectMessage, titled with the email.
- Match conversations by name only, not ID, and drop the ID tail.
- Treat the epoch Google reports for an unset time as absent.
* fix(gatekeeper-google): re-read retried Chat sends; list only named picker conversations
- A retried create gets its request echoed, not the stored message, so
re-read it before rebasing queued edits, in every conversation type.
The test backend now echoes the request as Google documents.
- The picker omits unnamed DMs and group chats from browse; they are
reached by email or pasted link.
* fix(gatekeeper-google): finish a retried Chat edit undo instead of reporting a conflict
An undo whose PATCH landed but lost its response left the revert record,
and the retry read the restored text as a later external edit. Text that
already matches the pre-edit version now counts as undone, like the
other Chat reverts that tolerate a retry.
* fix(gatekeeper-google): finish retried Chat edits whose lost write Chat rendered
A retried edit found Chat's rendered form of its own lost write (a
mention stored as @Name), matched neither queued text, and threw a
conflict while reject was refused as possibly applied: a stuck approval.
A retry now rewrites instead; first attempts keep the conflict check.
messages.patch is also retried by the fetch layer, since resending the
same text is idempotent.
* docs(gatekeeper-google): describe Chat edit conflicts and retried writes
* fix(gatekeeper-google): keep uncertain Chat actions recoverable
- An edit whose text Chat already holds records no undo, so undo can't
reverse the owner's own identical edit.
- Edits, reactions and sends read their target message through one path;
once it is deleted, the uncertain-write mark clears and the error says
to reject the change.
- Every send re-reads the created message, so an echoed or deleted
message is never recorded as sent.
- A send posted outside the connection's scope, or without its thread, is
deleted again so the action stays rejectable; a failed removal is
reported and logged, and keeps the action unrejectable.
- Retried reactions keep their undo.
- reply and edit record their message read as an observation.
- Token refresh failures release the rejected response body.
- Correct the profileNames failure contract.
* fix(gatekeeper-google): take back Chat replies posted outside their thread
Check every reply's created thread against the requested one, not only on
thread bindings, so a reply Google posts top-level from a space or account
binding is deleted again and its action stays rejectable.
* fix(gatekeeper-google): harden Chat undo, sends and attachment downloads
- Undoing a change whose message was deleted counts as done instead of
failing on every retry; the undo switch moves into one undo() helper.
- Apply and undo share an in-flight guard, so a double-clicked undo
cannot run twice.
- Attachment media names may not contain . or .. segments.
- Sends carrying a requestId are retried on transient failures.
- A 401 is an auth failure, not "no access".
- A failed conversation name is logged rather than silently dropped.
- Note that a person's membership ID is their user ID, and look members
up by membership name in the test backend.
---------
Co-authored-by: Nathan Disidore <nathan@cloudflare.com> B
Ben Yule committed
1c5f5b8ea06ea043891e6cd1b2192e3fc0aba869
Parent: a9adc80
Committed by GitHub <noreply@github.com>
on 9/29/2026, 10:04:41 PM