SIGN IN SIGN UP

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