@open-everywhere/provider-google-chat 0.0.0-stage → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +51 -0
  3. package/README.md +40 -2
  4. package/agent/channels/google-chat-auth.ts +145 -0
  5. package/agent/channels/google-chat-front.ts +338 -0
  6. package/agent/channels/google-chat-identity.ts +97 -0
  7. package/agent/channels/google-chat-message-ops.ts +30 -0
  8. package/agent/channels/google-chat-origin-codec.ts +32 -0
  9. package/agent/channels/google-chat-post-arm.ts +36 -0
  10. package/agent/channels/google-chat-rest.ts +87 -0
  11. package/agent/channels/google-chat.ts +194 -0
  12. package/agent/contributions.ts +16 -0
  13. package/agent/docs-generated.ts +120 -0
  14. package/agent/policy.ts +101 -0
  15. package/agent/provider-events.ts +18 -0
  16. package/agent/register.ts +48 -0
  17. package/agent/surface-parity.ts +64 -0
  18. package/dist/agent/channels/google-chat-auth.d.ts +39 -0
  19. package/dist/agent/channels/google-chat-auth.d.ts.map +1 -0
  20. package/dist/agent/channels/google-chat-auth.js +118 -0
  21. package/dist/agent/channels/google-chat-auth.js.map +1 -0
  22. package/dist/agent/channels/google-chat-front.d.ts +102 -0
  23. package/dist/agent/channels/google-chat-front.d.ts.map +1 -0
  24. package/dist/agent/channels/google-chat-front.js +258 -0
  25. package/dist/agent/channels/google-chat-front.js.map +1 -0
  26. package/dist/agent/channels/google-chat-identity.d.ts +28 -0
  27. package/dist/agent/channels/google-chat-identity.d.ts.map +1 -0
  28. package/dist/agent/channels/google-chat-identity.js +70 -0
  29. package/dist/agent/channels/google-chat-identity.js.map +1 -0
  30. package/dist/agent/channels/google-chat-message-ops.d.ts +10 -0
  31. package/dist/agent/channels/google-chat-message-ops.d.ts.map +1 -0
  32. package/dist/agent/channels/google-chat-message-ops.js +25 -0
  33. package/dist/agent/channels/google-chat-message-ops.js.map +1 -0
  34. package/dist/agent/channels/google-chat-origin-codec.d.ts +6 -0
  35. package/dist/agent/channels/google-chat-origin-codec.d.ts.map +1 -0
  36. package/dist/agent/channels/google-chat-origin-codec.js +31 -0
  37. package/dist/agent/channels/google-chat-origin-codec.js.map +1 -0
  38. package/dist/agent/channels/google-chat-post-arm.d.ts +11 -0
  39. package/dist/agent/channels/google-chat-post-arm.d.ts.map +1 -0
  40. package/dist/agent/channels/google-chat-post-arm.js +31 -0
  41. package/dist/agent/channels/google-chat-post-arm.js.map +1 -0
  42. package/dist/agent/channels/google-chat-rest.d.ts +21 -0
  43. package/dist/agent/channels/google-chat-rest.d.ts.map +1 -0
  44. package/dist/agent/channels/google-chat-rest.js +81 -0
  45. package/dist/agent/channels/google-chat-rest.js.map +1 -0
  46. package/dist/agent/channels/google-chat.d.ts +3 -0
  47. package/dist/agent/channels/google-chat.d.ts.map +1 -0
  48. package/dist/agent/channels/google-chat.js +157 -0
  49. package/dist/agent/channels/google-chat.js.map +1 -0
  50. package/dist/agent/contributions.d.ts +12 -0
  51. package/dist/agent/contributions.d.ts.map +1 -0
  52. package/dist/agent/contributions.js +16 -0
  53. package/dist/agent/contributions.js.map +1 -0
  54. package/dist/agent/docs-generated.d.ts +3 -0
  55. package/dist/agent/docs-generated.d.ts.map +1 -0
  56. package/dist/agent/docs-generated.js +118 -0
  57. package/dist/agent/docs-generated.js.map +1 -0
  58. package/dist/agent/policy.d.ts +38 -0
  59. package/dist/agent/policy.d.ts.map +1 -0
  60. package/dist/agent/policy.js +78 -0
  61. package/dist/agent/policy.js.map +1 -0
  62. package/dist/agent/provider-events.d.ts +3 -0
  63. package/dist/agent/provider-events.d.ts.map +1 -0
  64. package/dist/agent/provider-events.js +16 -0
  65. package/dist/agent/provider-events.js.map +1 -0
  66. package/dist/agent/register.d.ts +3 -0
  67. package/dist/agent/register.d.ts.map +1 -0
  68. package/dist/agent/register.js +37 -0
  69. package/dist/agent/register.js.map +1 -0
  70. package/dist/agent/surface-parity.d.ts +3 -0
  71. package/dist/agent/surface-parity.d.ts.map +1 -0
  72. package/dist/agent/surface-parity.js +60 -0
  73. package/dist/agent/surface-parity.js.map +1 -0
  74. package/package.json +116 -4
  75. package/tsconfig.build.json +22 -0
  76. package/tsconfig.json +15 -0
@@ -0,0 +1,194 @@
1
+ // The GOOGLE CHAT surface — the agent as a Google Chat app (OPE-2442). A custom `defineChannel` HTTP transport
2
+ // (eve ships no Google Chat channel): `POST /eve/v1/google-chat` is the app's HTTP endpoint. The eve-free
3
+ // orchestration is google-chat-front.ts; this file is the live wiring — the store, the config, and the two seat
4
+ // dispatches.
5
+ //
6
+ // THE LOOP:
7
+ // POST /eve/v1/google-chat
8
+ // → handleGoogleChatRequest (google-chat-front.ts) holds the route's order, and a test pins it:
9
+ // → the surface SHIPS OFF: a disabled policy (or one with no audience) answers 503 before the body is read
10
+ // → the bearer token is verified against Google's keys and the policy's audience (fail closed: 401)
11
+ // → the body is read ONCE, capped (413), and the route acknowledges `{}` (no synchronous message) so a slow seat
12
+ // never makes Google time the event out; the rest runs in the background
13
+ // → parse, dedupe on the message name, route: a mention in a space diverts into the standing seat of the
14
+ // domain whose `reach` lists `google-chat/<workspace>/<space id>` — the same
15
+ // `divertInboundToUnifiedWindow` every chat surface's front door uses; the person's own DM with the app
16
+ // diverts into their DM seat (channels/dm-seat.ts)
17
+ // → the seat answers with `reply`, which the post arm (google-chat-post-arm.ts) sends into the same space
18
+ // and thread, as the app.
19
+ //
20
+ // Spend, the wake ingress row, provider-selection admission, rate-limit retry and the audit of the wake all ride
21
+ // the shared divert. Nothing here re-implements them.
22
+
23
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
24
+ import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
25
+ import {
26
+ appendAudit,
27
+ claimRuntimeRotation,
28
+ carryTailForward,
29
+ deploymentOrg,
30
+ divertInboundToUnifiedWindow,
31
+ divertToUnifiedWindow,
32
+ dmWindowRegistryKey,
33
+ getConfig,
34
+ getOperationalStore,
35
+ getOrCreateDmSeat,
36
+ grainUsesUnifiedWindow,
37
+ mintRotationToken,
38
+ mintWakeId,
39
+ receiptEventTime,
40
+ recordWakeTail,
41
+ resolveWakeTrustFromContext,
42
+ shouldRotate,
43
+ spreadVendorTs,
44
+ stampMintedRuntime,
45
+ supervisorGrainFor,
46
+ tailAuthorGrain,
47
+ touchDmSeat,
48
+ withEndsTurnFinalChannel,
49
+ type BindableContext,
50
+ type UnifiedWindowWake,
51
+ type WakeMessageInput,
52
+ } from "@open-everywhere/core/agent/adapter-api.js";
53
+ import { googleChatPolicy } from "../policy.js";
54
+ import { handleGoogleChatRequest, renderGoogleChatWake, type GoogleChatFrontDeps, type GoogleChatWakePlan } from "./google-chat-front.js";
55
+
56
+ const ROUTE = "/eve/v1/google-chat";
57
+ /** How long a message name stays claimed — longer than Google's retry window. */
58
+ const DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
59
+
60
+ function log(msg: string): void {
61
+ console.log(`[open-everywhere:google-chat] ${msg}`);
62
+ }
63
+
64
+ function authContext(auth: { attributes?: Readonly<Record<string, string>>; principalId?: string }): BindableContext {
65
+ return { channel: { kind: "google-chat" }, session: { auth: { current: auth as never } } };
66
+ }
67
+
68
+ /** The tail row both seats file after the wake landed: the trigger only. */
69
+ function tailRow(plan: GoogleChatWakePlan, atIso: string) {
70
+ return {
71
+ kind: "wake" as const,
72
+ at: atIso,
73
+ from: "human" as const,
74
+ trust: plan.trigger.trust,
75
+ ...tailAuthorGrain(plan.ingested[0] ?? { trust: plan.trigger.trust }),
76
+ author: plan.trigger.author,
77
+ authorId: plan.trigger.authorId ?? "",
78
+ id: plan.trigger.id,
79
+ platform: "google-chat",
80
+ container: plan.surfaceKey,
81
+ ...(plan.origin.thread !== undefined ? { threadRoot: plan.origin.thread } : {}),
82
+ text: plan.trigger.body,
83
+ };
84
+ }
85
+
86
+ /** The ROOM dispatch: the domain's standing seat, through the shared inbound divert. */
87
+ async function liveDispatchRoom(plan: GoogleChatWakePlan, to: RouteHandlerArgs["to"]): Promise<void> {
88
+ const org = deploymentOrg();
89
+ const store = getOperationalStore();
90
+ const grain = supervisorGrainFor(plan.surfaceKey, getConfig().loadDomains());
91
+ const atIso = new Date().toISOString();
92
+ const perContainer = (gen: number): string => `google-chat:${plan.surfaceKey}:oe-sup:g${gen}`;
93
+ // OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally.
94
+ const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
95
+ const existing = await store.getChannelSession(org, grain.registryKey);
96
+ let token: string;
97
+ let tail: readonly WakeMessageInput[] = [];
98
+ if (!existing) {
99
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => perContainer(1)), atIso);
100
+ await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
101
+ token = row.token;
102
+ } else if (shouldRotate(existing, atIso, { runtimeChanged })) {
103
+ const gen = existing.generation + 1;
104
+ tail = await carryTailForward(org, grain.registryKey, existing.token);
105
+ token = (await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => perContainer(gen)), atIso)).token;
106
+ } else {
107
+ token = existing.token;
108
+ }
109
+ const wakeId = mintWakeId();
110
+ const rendered = renderGoogleChatWake(plan, wakeId, tail);
111
+ const window: UnifiedWindowWake = { wakeId, origin: plan.origin, addressing: "direct-mention", ...spreadVendorTs(receiptEventTime(Date.now())) };
112
+ await divertInboundToUnifiedWindow(to, {
113
+ message: rendered.message,
114
+ ...(rendered.context.length > 0 ? { context: rendered.context } : {}),
115
+ token,
116
+ window,
117
+ surfaceKey: plan.surfaceKey,
118
+ auth: plan.auth as unknown as { attributes?: Record<string, unknown> },
119
+ });
120
+ await store.touchChannelSession(org, grain.registryKey, atIso).catch(() => undefined);
121
+ await recordWakeTail(org, grain.registryKey, token, tailRow(plan, atIso)).catch(() => undefined);
122
+ }
123
+
124
+ /** The DM dispatch: the person's own DM seat. */
125
+ async function liveDispatchDm(plan: GoogleChatWakePlan, to: RouteHandlerArgs["to"]): Promise<void> {
126
+ const org = deploymentOrg();
127
+ const atIso = new Date().toISOString();
128
+ const seat = await getOrCreateDmSeat(org, { surfaceKey: plan.surfaceKey }, atIso);
129
+ const wakeId = mintWakeId();
130
+ const rendered = renderGoogleChatWake(plan, wakeId, seat.freshGeneration ? seat.tail : undefined);
131
+ await divertToUnifiedWindow(to, {
132
+ message: rendered.message,
133
+ ...(rendered.context.length > 0 ? { context: rendered.context } : {}),
134
+ token: seat.token,
135
+ window: { wakeId, origin: plan.origin, addressing: "direct-mention", ...spreadVendorTs(receiptEventTime(Date.now())) },
136
+ // Byte-exactly the surface named inside the DM token — the privacy fence both admission gates match on.
137
+ surfaceKey: plan.surfaceKey,
138
+ auth: plan.auth as unknown as { attributes?: Record<string, unknown> },
139
+ });
140
+ await touchDmSeat(org, plan.surfaceKey, atIso).catch(() => undefined);
141
+ await recordWakeTail(org, dmWindowRegistryKey(plan.surfaceKey), seat.token, tailRow(plan, atIso)).catch(() => undefined);
142
+ }
143
+
144
+ /** The live front deps over this request's `to`. */
145
+ function liveGoogleChatFrontDeps(to: RouteHandlerArgs["to"]): GoogleChatFrontDeps {
146
+ return {
147
+ policy: googleChatPolicy,
148
+ markSeen: (key) => getOperationalStore().markEventSeen(deploymentOrg(), key, new Date().toISOString(), DEDUPE_TTL_MS),
149
+ roomHasSeat: (surfaceKey) => {
150
+ try {
151
+ return grainUsesUnifiedWindow(supervisorGrainFor(surfaceKey, getConfig().loadDomains()));
152
+ } catch {
153
+ return false;
154
+ }
155
+ },
156
+ wakeTrust: (auth, sender) => resolveWakeTrustFromContext(authContext(auth), { id: sender, isBot: false }, { principalFallback: true }),
157
+ dispatchRoom: (plan) => liveDispatchRoom(plan, to),
158
+ dispatchDm: (plan) => liveDispatchDm(plan, to),
159
+ audit: (row) =>
160
+ appendAudit({
161
+ org: deploymentOrg(),
162
+ platform: "google-chat",
163
+ workspace: row.workspace,
164
+ ...(row.surface ? { surface: row.surface } : {}),
165
+ ...(row.principalId ? { principalId: row.principalId } : {}),
166
+ action: row.action,
167
+ detail: row.detail,
168
+ }).catch(() => undefined),
169
+ };
170
+ }
171
+
172
+ const channel = defineChannel({
173
+ routes: [
174
+ POST(ROUTE, (req, { to, waitUntil }) =>
175
+ handleGoogleChatRequest(req, waitUntil, liveGoogleChatFrontDeps(to), {
176
+ log,
177
+ // The event was acknowledged and its name claimed, so a retry is a duplicate: this row is the only trace
178
+ // of the lost message. The error class only: an error message can carry message content.
179
+ onFailure: (err, policy) =>
180
+ appendAudit({
181
+ org: deploymentOrg(),
182
+ platform: "google-chat",
183
+ workspace: policy.workspace,
184
+ action: "google-chat.wake_failed",
185
+ detail: { error: auditErrorDetail(err instanceof Error ? err.name : "error").error },
186
+ })
187
+ .then(() => undefined)
188
+ .catch(() => undefined),
189
+ }),
190
+ ),
191
+ ],
192
+ });
193
+
194
+ export default withEndsTurnFinalChannel(channel);
@@ -0,0 +1,16 @@
1
+ // THE CONTRIBUTION MANIFEST for `@open-everywhere/provider-google-chat` (OPE-2442) — the curated catalogue of
2
+ // everything this package OFFERS an app's eve slot tree. `oe build` reads it; the app's `roster.config.ts` decides
3
+ // about it, and `roster.lock.json` records the review. See packages/providers/github/agent/contributions.ts for why
4
+ // the manifest is curated, import-free data.
5
+ //
6
+ // ERASABLE SYNTAX ONLY (no `enum`, no `namespace`) — `oe build` loads this with a bare import.
7
+
8
+ export const CONTRIBUTIONS = {
9
+ schemaVersion: 1,
10
+ package: "@open-everywhere/provider-google-chat",
11
+ register: "agent/register.js#registerGoogleChatProvider",
12
+ contributions: [
13
+ // ── channels ─────────────────────────────────────────────────────────────────────────────────
14
+ { kind: "channel", name: "google-chat", module: "agent/channels/google-chat.js", summary: "Google Chat — the agent as a Chat app: @mentions in spaces and direct messages in, threaded replies out." },
15
+ ],
16
+ } as const;
@@ -0,0 +1,120 @@
1
+ // GENERATED by `npx tsx scripts/docs-generate.ts` from docs/deploy/surfaces/google-chat.md — DO NOT HAND-EDIT.
2
+ // Edit the markdown under docs/ and re-run the generator; `npm run lint:docs` fails the
3
+ // build if this file and its sources ever disagree. See scripts/docs-generate.ts for why the
4
+ // manual is compiled in at all (docs/ does not exist on disk in a hosted deployment).
5
+
6
+ import type { DocEntry } from "@open-everywhere/core/agent/adapter-api.js";
7
+
8
+ export const PROVIDER_DOCS: readonly DocEntry[] = [
9
+ {
10
+ title: "Google Chat",
11
+ path: "docs/deploy/surfaces/google-chat.md",
12
+ summary: "Put the agent in Google Chat as a Chat app. People @mention it in a space or send it a direct message, and it answers in the same thread. Google Chat reaches the same two chiefs of staff as every other chat surface:",
13
+ markdown: [
14
+ "# Google Chat",
15
+ "",
16
+ "Put the agent in Google Chat as a Chat app. People @mention it in a space or send it a direct message, and it",
17
+ "answers in the same thread. Google Chat reaches the same two chiefs of staff as every other chat surface:",
18
+ "",
19
+ "- A **space** belongs to a domain. An @mention of the app in a space wakes that domain's standing seat, exactly as a",
20
+ " message in one of the domain's Slack or Teams channels would.",
21
+ "- A **direct message** with the app wakes the person's own DM seat.",
22
+ "",
23
+ "Google Chat is the surface for an organisation that runs Google Workspace. It ships **off**: until a policy enables",
24
+ "it, the endpoint answers every delivery with `503`.",
25
+ "",
26
+ "## What it does and does not do",
27
+ "",
28
+ "- **Every event is verified before it is read.** Google Chat sends each event with a bearer token. The route checks",
29
+ " the token's signature against Google's published keys, its issuer, its expiry, and its audience against the",
30
+ " policy's `audience`. A token for any other audience, or (in endpoint-URL mode) from any Google account other than",
31
+ " `chat@system.gserviceaccount.com`, is refused `401` and writes no audit row.",
32
+ "- **Addressed only.** The HTTP endpoint gets a space message only when it @-mentions the app, or invokes one of its",
33
+ " slash commands. The surface has no ambient leg: room chatter never reaches it. (A Workspace Events subscription",
34
+ " would deliver the rest; this surface does not use one yet.)",
35
+ "- **Trust comes from the policy, never from a name.** A sender binds `internal` only when Google's event names a",
36
+ " Workspace domain id (`sender.domainId`) that the policy lists in `internalDomainIds`. Everyone else binds",
37
+ " `external`: shared memory routes to the read-only `public` layer, and the turn is marked as carrying a",
38
+ " non-member's words. ⚠ The mapping from your Workspace to its domain id is not documented as stable by Google.",
39
+ " Read the id from a real event: every `google-chat.admitted` and `google-chat.refused` audit row records the",
40
+ " sender's `domainId`. A DM-only deployment can start with an empty list: the first internal person's DM is refused",
41
+ " `external-dm`, and its refused row carries their domain id.",
42
+ "- **Direct messages are for internal people only.** A DM seat reads its person's private layer, so a DM from an",
43
+ " `external` sender is refused (`external-dm`). Set `directMessages: false` to refuse every DM (`dm-off`).",
44
+ "- **Group chats are refused** (`group-chat`). A group chat has no domain to own it and is not one person's DM.",
45
+ "- **A space with no seat is refused**, not dropped in silence (`no-domain`): put its surface key in a domain's",
46
+ " `reach`, or run a standing-seat window that gives an undeclared space its own solo seat.",
47
+ "- **Bots never wake a seat.** A message from any bot, the app's own included, is dropped. That is the loop guard.",
48
+ "- **Replies only, into the conversation that woke the seat.** The reply posts into the trigger's space and thread,",
49
+ " as the app. The space and thread are frozen at the gate from the verified event; the model never names one. The",
50
+ " app can post only into a space it is a member of. A reply longer than one Chat message goes out as consecutive",
51
+ " messages in the same thread.",
52
+ "",
53
+ "## How it fits the planes",
54
+ "",
55
+ "| Plane | What Google Chat uses |",
56
+ "|---|---|",
57
+ "| Surface | The `google-chat` provider bundle (`packages/providers/google-chat`): identity mapper, capability manifest, post arm, wake-origin codec, host-notice transport. A space's surface key is `google-chat/<workspace>/<space id>` and goes in a domain's `reach` like any channel. |",
58
+ "| Seat | A space mention diverts into the domain's standing seat through the same `divertInboundToUnifiedWindow` every chat surface uses; a DM diverts into the person's DM seat (`channels/dm-seat.ts`). Spend caps, the wake ingress row, provider selection and rate-limit retry ride the shared divert. |",
59
+ "| Identity | The principal is the sender's Chat user resource name (`users/<id>`). The workspace is the policy's `workspace` segment. Visibility is always `private`. |",
60
+ "| Audit | `google-chat.admitted` (space, seat kind, trust, the sender's domain id, whether it was a thread reply), `google-chat.refused` (the reason and the sender's domain id), and `google-chat.wake_failed`. The reply itself writes the shared `reply.post` row. |",
61
+ "| Egress | The reply and a host notice (an approval outcome note, the judge-lift notice) are the only outbound calls, to `chat.googleapis.com`, with a `chat.bot` token minted from the app's service-account key at `oauth2.googleapis.com`. The token endpoint is pinned: the key file's own `token_uri` is ignored. |",
62
+ "",
63
+ "## What an operator must provision",
64
+ "",
65
+ "Nothing below is created by Open Everywhere.",
66
+ "",
67
+ "1. **A Google Cloud project** with the Google Chat API enabled.",
68
+ "2. **A service account** in that project, and a JSON key for it. The app posts its replies as this account.",
69
+ "3. **The Chat app configuration** (Google Chat API → Configuration): give the app a name and avatar, turn on",
70
+ " \"Receive 1:1 messages\" and \"Join spaces and group conversations\", choose **HTTP endpoint URL** as the connection",
71
+ " setting, and set it to `https://<your deployment host>/eve/v1/google-chat`. Choose the **Authentication audience**:",
72
+ " \"HTTP endpoint URL\" (recommended) or \"Project number\". Make the app visible to the people or groups who should",
73
+ " use it.",
74
+ "4. **The secret**, in the secret store, never in a file or the environment of a commit:",
75
+ " ```sh",
76
+ " npx oe secret set GOOGLE_CHAT_SERVICE_ACCOUNT_KEY # the service account's JSON key, whole",
77
+ " ```",
78
+ " (The resolver falls back to an environment variable of the same name, and warns when it does.)",
79
+ "5. **The policy**, in `agent/google-chat/policy.ts`, by pull request: set `enabled: true`, the `audience` that",
80
+ " matches step 3 (`{ kind: \"endpoint-url\", url: \"https://<host>/eve/v1/google-chat\" }` or",
81
+ " `{ kind: \"project-number\", projectNumber: \"<number>\" }`), your `workspace` segment, and your",
82
+ " `internalDomainIds`.",
83
+ "6. **The seats.** For each space, add its surface key (`google-chat/<workspace>/<space id>`, the id after",
84
+ " `spaces/` in the space's URL) to the owning domain's `reach` in `agent/memory/config/domains/`. A multi-source",
85
+ " window requires every source vouched, so also declare the space in `agent/identity/surfaces.ts`. 🔴 The vouch",
86
+ " does **not** make an outside sender internal: the mapper states each message's trust itself, from the policy.",
87
+ "7. **Add the app** to each space (`@<app name>` → Add).",
88
+ "",
89
+ "## Settings",
90
+ "",
91
+ "| Field | Meaning |",
92
+ "|---|---|",
93
+ "| `enabled` | The master switch. Off answers `503` to every delivery and refuses every reply. |",
94
+ "| `audience` | The audience every event's token must carry. `null` refuses every delivery; an enabled policy without one fails the boot. |",
95
+ "| `workspace` | The workspace segment of every surface key (lowercase, `a-z`, `0-9`, `-`). |",
96
+ "| `internalDomainIds` | The Workspace domain ids whose people bind `internal`. Empty: everybody binds `external`. |",
97
+ "| `directMessages` | May an internal person wake their own DM seat? |",
98
+ "",
99
+ "## What is not built yet",
100
+ "",
101
+ "- **Cards**: an approval asked from Google Chat takes the venue walk to a card-capable room in the same domain. A",
102
+ " Chat card with a button needs the `CARD_CLICKED` tap answered.",
103
+ "- **Reading a space** (`read_channel`, `read_thread`) and a seat catch-up: listing a space's messages needs user",
104
+ " authentication or the admin-approved `chat.app.messages.readonly` app scope, which the app does not request.",
105
+ "- **Ambient messages, name-drops and edits**: they need a Workspace Events subscription.",
106
+ "- **Edit, reactions, stop, files, proactive posts and routines.** Each is declared, with its reason, in the",
107
+ " [chat-surface contract](../../extending/chat-surface-contract.md)'s parity tables.",
108
+ "- **The Workspace add-on event format.** The route reads the classic Chat app event; an app built as a Google",
109
+ " Workspace add-on wraps its events differently and is not supported.",
110
+ "",
111
+ "## Verification",
112
+ "",
113
+ "The unit packs (`test:google-chat-auth`, `test:google-chat-front`, `test:google-chat-post-arm`) and the app-level",
114
+ "`test:google-chat-integration` drive the token check, the gate, the reply and the binding offline. There is no",
115
+ "live e2e leg yet: one needs a Google Workspace test tenant with the app published to it, a fixture this repository",
116
+ "cannot create.",
117
+ "",
118
+ ].join("\n"),
119
+ },
120
+ ];
@@ -0,0 +1,101 @@
1
+ // THE GOOGLE CHAT POLICY (OPE-2442) — the schema, its validation and the registry. The app's own
2
+ // `agent/google-chat/policy.ts` declares the values and calls `registerGoogleChatPolicy` at boot (through
3
+ // `agent/lib/register-config.ts`), so every change is a reviewed pull request.
4
+ //
5
+ // 🔴 THE SURFACE SHIPS OFF. `DEFAULT_GOOGLE_CHAT_POLICY.enabled` is false, and the route answers 503 to every
6
+ // delivery until a policy enables it AND names the audience Google signs each event for. An external ingress
7
+ // that nobody configured must not be reachable.
8
+ //
9
+ // 🔴 TRUST IS TYPED CODE, NEVER AN ENVIRONMENT VARIABLE. `internalDomainIds` is the list of Google Workspace
10
+ // domain ids whose people bind `internal`; it is a trust declaration, so it lives here and changes by pull
11
+ // request. Nothing here is a secret: the app's service-account key lives in the secret store
12
+ // (`npx oe secret set GOOGLE_CHAT_SERVICE_ACCOUNT_KEY`).
13
+
14
+ /** Which audience Google Chat puts in the bearer token of each event — the app's "Authentication audience"
15
+ * setting in the Google Cloud console. */
16
+ export type GoogleChatAudience =
17
+ /** "Project number": the token is self-signed by `chat@system.gserviceaccount.com` for the project number. */
18
+ | { readonly kind: "project-number"; readonly projectNumber: string }
19
+ /** "HTTP endpoint URL": the token is a Google OIDC id token for the exact endpoint URL. */
20
+ | { readonly kind: "endpoint-url"; readonly url: string };
21
+
22
+ export interface GoogleChatPolicy {
23
+ /** The master switch. Off answers 503 to every delivery and refuses every reply. */
24
+ readonly enabled: boolean;
25
+ /** The audience every event's token must carry. Null refuses every delivery (fail closed). */
26
+ readonly audience: GoogleChatAudience | null;
27
+ /** The workspace segment of every Google Chat surface key (`google-chat/<workspace>/<space id>`). */
28
+ readonly workspace: string;
29
+ /**
30
+ * The Google Workspace domain ids (`sender.domainId` on a Chat event) whose people bind `internal`. Everyone
31
+ * else binds `external`. Empty: everybody binds external.
32
+ */
33
+ readonly internalDomainIds: readonly string[];
34
+ /** May a person wake their own DM seat by messaging the app directly? An `external` person never can. */
35
+ readonly directMessages: boolean;
36
+ }
37
+
38
+ export const DEFAULT_GOOGLE_CHAT_POLICY: GoogleChatPolicy = {
39
+ enabled: false,
40
+ audience: null,
41
+ workspace: "workspace",
42
+ internalDomainIds: [],
43
+ directMessages: true,
44
+ };
45
+
46
+ const WORKSPACE_SEGMENT = /^[a-z0-9][a-z0-9-]{0,62}$/u;
47
+ const PROJECT_NUMBER = /^[0-9]{1,20}$/u;
48
+ const DOMAIN_ID = /^[A-Za-z0-9_-]{1,64}$/u;
49
+
50
+ /** Every problem with `policy`, or empty when it is valid. Pure. */
51
+ export function validateGoogleChatPolicy(policy: GoogleChatPolicy): readonly string[] {
52
+ const problems: string[] = [];
53
+ if (!WORKSPACE_SEGMENT.test(policy.workspace)) {
54
+ problems.push(`workspace "${policy.workspace}" must be a lowercase segment (a-z, 0-9, "-"), at most 63 characters.`);
55
+ }
56
+ for (const id of policy.internalDomainIds) {
57
+ if (!DOMAIN_ID.test(id)) problems.push(`internalDomainIds entry "${id}" is not a Google Workspace domain id.`);
58
+ }
59
+ const a = policy.audience;
60
+ if (a !== null) {
61
+ if (a.kind === "project-number") {
62
+ if (!PROJECT_NUMBER.test(a.projectNumber)) problems.push("audience.projectNumber must be the numeric Google Cloud project number.");
63
+ } else if (a.kind === "endpoint-url") {
64
+ let url: URL | undefined;
65
+ try {
66
+ url = new URL(a.url);
67
+ } catch {
68
+ url = undefined;
69
+ }
70
+ if (url?.protocol !== "https:") problems.push("audience.url must be the app's https endpoint URL.");
71
+ } else {
72
+ problems.push(`audience.kind must be "project-number" or "endpoint-url".`);
73
+ }
74
+ }
75
+ if (policy.enabled && a === null) problems.push("an enabled policy must name its audience — without it no event can be verified.");
76
+ return problems;
77
+ }
78
+
79
+ let registered: GoogleChatPolicy = DEFAULT_GOOGLE_CHAT_POLICY;
80
+
81
+ /** Register this deployment's policy. Throws on an invalid policy, so a bad file fails the boot. */
82
+ export function registerGoogleChatPolicy(policy: GoogleChatPolicy): void {
83
+ const problems = validateGoogleChatPolicy(policy);
84
+ if (problems.length > 0) throw new Error(`agent/google-chat/policy.ts is invalid:\n - ${problems.join("\n - ")}`);
85
+ registered = policy;
86
+ }
87
+
88
+ /** The registered policy (the shipped default — off — until the app registers one). */
89
+ export function googleChatPolicy(): GoogleChatPolicy {
90
+ return registered;
91
+ }
92
+
93
+ /** The surface key of one space: `google-chat/<workspace>/<space id>`. */
94
+ export function googleChatSurfaceKey(policy: GoogleChatPolicy, space: string): string {
95
+ return `google-chat/${policy.workspace}/${spaceId(space)}`;
96
+ }
97
+
98
+ /** The id after `spaces/`. */
99
+ function spaceId(space: string): string {
100
+ return space.slice(space.indexOf("/") + 1);
101
+ }
@@ -0,0 +1,18 @@
1
+ // GOOGLE CHAT's event inventory (OPE-2442). See the core module's header
2
+ // (packages/core/agent/routines/provider-events.ts) for what the three routing states mean.
3
+ //
4
+ // 🔴 THE GOOGLE CHAT BUNDLE REGISTERS NO EVENT NORMALIZER, so every row is `routing: "none"` and the declaration
5
+ // names no `source`. A message event is CONVERSATION — it wakes a seat through the surface's gate — and a routine
6
+ // subscribed to raw Chat events would bypass that gate.
7
+
8
+ import type { ProviderEventDeclaration } from "@open-everywhere/core/agent/adapter-api.js";
9
+
10
+ export const GOOGLE_CHAT_PROVIDER_EVENTS: ProviderEventDeclaration = {
11
+ doc: "https://developers.google.com/workspace/chat/receive-respond-interactions",
12
+ events: [
13
+ { event: "MESSAGE", routing: "none", match: [], scope: [], note: "an @mention in a space or a direct message — the front's conversation leg, through the gate, never a routine" },
14
+ { event: "ADDED_TO_SPACE", routing: "none", match: [], scope: [], note: "the app was added to a space — acknowledged and dropped; a space wakes nothing until a domain's `reach` lists it" },
15
+ { event: "REMOVED_FROM_SPACE", routing: "none", match: [], scope: [], note: "the app was removed from a space — acknowledged and dropped" },
16
+ { event: "CARD_CLICKED", routing: "none", match: [], scope: [], note: "a card button tap — acknowledged and dropped; the surface posts no card yet" },
17
+ ],
18
+ };
@@ -0,0 +1,48 @@
1
+ // registerGoogleChatProvider — the ONE boot entry of @open-everywhere/provider-google-chat (OPE-2442). The app's
2
+ // generated selection table imports it STATICALLY and `registerDefaultAdapters()` calls it once at boot; every
3
+ // seam the bundle implements registers here and nowhere else.
4
+ //
5
+ // What Google Chat registers (and what it deliberately does NOT):
6
+ // • identity mapper — bindGoogleChat (channels/google-chat-identity.ts)
7
+ // • capability manifest — { midTurnPost: true, reactions: false, closingProseDropped: true }: `reply` is the
8
+ // wired verb (the unified dispatch through the post arm), the app sets no reactions,
9
+ // and a seat's turn-final text is dropped, so `reply` is the only way out
10
+ // • post arm — googleChatPostArm: the reply, into the trigger's space and thread
11
+ // • wake-origin codec — the stored-data google-chat arm (channels/google-chat-origin-codec.ts)
12
+ // • surface message ops — googleChatMessageOps: a host notice is one more message in the space
13
+ // • credential probe — "configured" = the policy is enabled (no environment read)
14
+ // • NO read adapter (the app cannot list a space's messages), NO card poster, NO reaction surface, NO routine
15
+ // dispatch — each is declared, with its reason, in surface-parity.ts.
16
+
17
+ import {
18
+ registerCapabilityManifest,
19
+ registerIdentityMapper,
20
+ registerPostArm,
21
+ registerProviderDocs,
22
+ registerProviderEvents,
23
+ registerSurfaceCredentialProbe,
24
+ registerSurfaceMessageOps,
25
+ registerSurfaceParity,
26
+ registerWakeOriginCodec,
27
+ } from "@open-everywhere/core/agent/adapter-api.js";
28
+ import { PROVIDER_DOCS } from "./docs-generated.js";
29
+ import { GOOGLE_CHAT_SURFACE_PARITY } from "./surface-parity.js";
30
+ import { GOOGLE_CHAT_PROVIDER_EVENTS } from "./provider-events.js";
31
+ import { googleChatPolicy } from "./policy.js";
32
+ import { bindGoogleChat } from "./channels/google-chat-identity.js";
33
+ import { googleChatPostArm } from "./channels/google-chat-post-arm.js";
34
+ import { googleChatWakeOriginCodec } from "./channels/google-chat-origin-codec.js";
35
+ import { googleChatMessageOps } from "./channels/google-chat-message-ops.js";
36
+
37
+ /** Perform ALL of the Google Chat bundle's registrations. Idempotent (each register call replaces). */
38
+ export function registerGoogleChatProvider(): void {
39
+ registerIdentityMapper("google-chat", bindGoogleChat);
40
+ registerCapabilityManifest("google-chat", { midTurnPost: true, reactions: false, closingProseDropped: true });
41
+ registerSurfaceParity("google-chat", GOOGLE_CHAT_SURFACE_PARITY);
42
+ registerProviderEvents("google-chat", GOOGLE_CHAT_PROVIDER_EVENTS);
43
+ registerPostArm(googleChatPostArm);
44
+ registerWakeOriginCodec("google-chat", googleChatWakeOriginCodec);
45
+ registerSurfaceMessageOps(googleChatMessageOps);
46
+ registerSurfaceCredentialProbe("google-chat", () => googleChatPolicy().enabled);
47
+ registerProviderDocs("google-chat", PROVIDER_DOCS);
48
+ }
@@ -0,0 +1,64 @@
1
+ // GOOGLE CHAT's surface-parity declaration (OPE-2442). See the core module's header
2
+ // (packages/core/agent/identity/surface-parity.ts) for what the three cell shapes mean.
3
+ //
4
+ // The first cut is the addressed path: an @mention in a space or a direct message wakes a seat, and the seat
5
+ // answers in the same thread. Google Chat's HTTP endpoint delivers a space message only when it @-mentions the
6
+ // app. Ambient messages, edits and space reads need a Workspace Events subscription or the admin-approved
7
+ // `chat.app.messages.readonly` scope, which this cut does not request. So every vendor-side gap below is "not
8
+ // built yet": none of them is a platform limit. Two kinds of row are not gaps: `mpim` is a policy refusal (a group
9
+ // chat has no domain to own it and is not one person's DM), and `search`, `origin-placement-carry`,
10
+ // `engaged-row-promotion` and `seat-context-admission` do not apply to this surface by design.
11
+
12
+ import type { SurfaceParityDeclaration } from "@open-everywhere/core/agent/adapter-api.js";
13
+
14
+ export const GOOGLE_CHAT_SURFACE_PARITY: SurfaceParityDeclaration = {
15
+ // --- registration seams (§7.1) ---------------------------------------------------------------
16
+ "identity-mapper": "supported",
17
+ "capability-manifest": "supported",
18
+ "post-arm": "supported",
19
+ "wake-origin-codec": "supported",
20
+ "origin-placement-carry": {
21
+ unsupported: "the placement is INSIDE the conversation identity: `threadRef` keys on the thread name, the very thread a reply posts into, so two origins of one conversation already agree on placement",
22
+ },
23
+ "read-adapter": { unsupported: "not built yet — listing a space's messages needs user authentication or the admin-approved `chat.app.messages.readonly` app scope, which the app does not request" },
24
+ "reaction-feedback-ingest": { unsupported: "not built yet — the HTTP endpoint carries no reaction event; a Workspace Events subscription would" },
25
+ "ancillary-ops": { supported: "text only — a host notice is one more message in the space; no edit, file or reaction" },
26
+ "ambient-engagement-seam": { unsupported: "not built yet — the HTTP endpoint delivers a space message only on an @mention; a Workspace Events subscription would deliver the rest" },
27
+ "engaged-row-promotion": { unsupported: "no ambient leg — every Google Chat wake is addressed (`direct-mention`)" },
28
+ "owned-thread-ingress": { supported: "the space leg takes the inbound divert; only a reply inside an existing thread (`threadReply`) is thread-scoped" },
29
+ "thread-done-reaction": { unsupported: "not built yet — the app sets no reactions (no `setReaction` in the ancillary ops)" },
30
+
31
+ // --- wake legs and conversation shapes -------------------------------------------------------
32
+ "addressed-leg": "supported",
33
+ "ambient-leg": { unsupported: "not built yet — the HTTP endpoint delivers a space message only on an @mention; a Workspace Events subscription would deliver the rest" },
34
+ "seat-catch-up": { unsupported: "not built yet — the app does not list a space's messages (see the read adapter row)" },
35
+ "seat-context-admission": { unsupported: "the wake fills no context-line slot — the seat reads only the addressed message and its carried tail" },
36
+ "approval-card": {
37
+ unsupported: "not built yet — a Chat card with a button needs the CARD_CLICKED tap answered; a governed act asked from Google Chat takes the venue walk to a card-capable room in the same domain",
38
+ },
39
+ "judge-lift-notice": { supported: "the shared text render, posted into the space — no button yet, so the overrule is spelled out in words" },
40
+ "out-of-office-cover": { unsupported: "not built yet — the space route does not call `coverForWake`, and the surface takes no ambient messages" },
41
+ "name-drop-mention": { unsupported: "not built yet — a message that names the app without an @mention never reaches the HTTP endpoint; a Workspace Events subscription would deliver it" },
42
+ "message-edit-wake": { unsupported: "not built yet — the HTTP endpoint sends no event for an edited message; a Workspace Events subscription would" },
43
+ threads: { supported: "a reply posts into the trigger's own thread (`REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`)" },
44
+ rooms: { supported: "a space is a surface a domain's `reach` lists (`google-chat/<workspace>/<space id>`)" },
45
+ dm: { supported: "the person's own direct message with the app wakes their DM seat — internal people only" },
46
+ mpim: { unsupported: "policy: a group chat is refused (`group-chat`) — it has no domain to own it and is not one person's DM" },
47
+
48
+ // --- core verbs (§7.2) -----------------------------------------------------------------------
49
+ reply: "supported",
50
+ edit_reply: { unsupported: "not built yet — the arm registers no edit; Chat's `messages.patch` would carry one" },
51
+ react: { unsupported: "not built yet — no outbound reaction transport is wired" },
52
+ no_reply: "supported",
53
+ read_channel: { unsupported: "not built yet — listing a space's messages needs user authentication or the admin-approved `chat.app.messages.readonly` app scope, which the app does not request" },
54
+ read_thread: { unsupported: "not built yet — the same message-list scope as `read_channel`" },
55
+ search: { unsupported: "no work-search adapter — Google Chat is a chat surface, not a work corpus" },
56
+ post_message: { unsupported: "not built yet — no routine dispatch is registered, so the agent answers only in a conversation somebody started" },
57
+ dm_person: { unsupported: "not built yet — the app cannot open a DM a person did not start" },
58
+ set_dm_delivery: { unsupported: "not built yet — a Google Chat DM is never a proactive delivery channel" },
59
+ stop: { unsupported: "not built yet — the app reads no reactions, so there is no stop trigger" },
60
+ ack_stop_glyph: { unsupported: "not built yet — the app sets no reactions" },
61
+ ack_cancel_note: { supported: "a message in the space, through the host-notice transport" },
62
+ stand_down: { unsupported: "not built yet — remove the app from the space instead" },
63
+ post_file: { unsupported: "not built yet — the reply carries text only" },
64
+ };
@@ -0,0 +1,39 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ import type { GoogleChatAudience } from "../policy.js";
3
+ /** The service account Google Chat signs its events as. */
4
+ export declare const CHAT_ISSUER_ACCOUNT = "chat@system.gserviceaccount.com";
5
+ export type GoogleChatTokenVerdict = {
6
+ readonly ok: true;
7
+ } | {
8
+ readonly ok: false;
9
+ readonly reason: string;
10
+ };
11
+ export interface GoogleChatVerifyDeps {
12
+ /** The key resolvers, injectable so a test signs with its own key. Live default: Google's JWKS. */
13
+ readonly chatSystemKeys?: JWTVerifyGetKey;
14
+ readonly googleOidcKeys?: JWTVerifyGetKey;
15
+ }
16
+ /**
17
+ * Verify one event's bearer token against the policy's audience. Fail closed: no header, a malformed token,
18
+ * a bad signature, a wrong issuer or audience, an expired token, or (endpoint-URL mode) a token for any
19
+ * account but Google Chat's own is refused.
20
+ */
21
+ export declare function verifyGoogleChatToken(authorization: string | undefined, audience: GoogleChatAudience | null, deps?: GoogleChatVerifyDeps): Promise<GoogleChatTokenVerdict>;
22
+ interface ServiceAccountKey {
23
+ readonly clientEmail: string;
24
+ readonly privateKey: string;
25
+ }
26
+ /** Parse the key JSON. Throws a key-free error on anything that is not a service-account key. */
27
+ export declare function parseServiceAccountKey(raw: string): ServiceAccountKey;
28
+ export interface GoogleChatTokenDeps {
29
+ readonly fetch?: typeof fetch;
30
+ /** The raw key JSON. Live default: the operator secret store. */
31
+ readonly serviceAccountKey?: () => Promise<string>;
32
+ readonly now?: () => number;
33
+ }
34
+ /** Forget the cached access token (tests). */
35
+ export declare function resetGoogleChatTokenCache(): void;
36
+ /** A `chat.bot` access token for the app's service account, cached until a minute before it expires. */
37
+ export declare function googleChatAccessToken(deps?: GoogleChatTokenDeps): Promise<string>;
38
+ export {};
39
+ //# sourceMappingURL=google-chat-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-chat-auth.d.ts","sourceRoot":"","sources":["../../../agent/channels/google-chat-auth.ts"],"names":[],"mappings":"AAYA,OAAO,EAAuD,KAAK,eAAe,EAAE,MAAM,MAAM,CAAC;AAEjG,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,2DAA2D;AAC3D,eAAO,MAAM,mBAAmB,oCAAoC,CAAC;AAarE,MAAM,MAAM,sBAAsB,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7G,MAAM,WAAW,oBAAoB;IACnC,mGAAmG;IACnG,QAAQ,CAAC,cAAc,CAAC,EAAE,eAAe,CAAC;IAC1C,QAAQ,CAAC,cAAc,CAAC,EAAE,eAAe,CAAC;CAC3C;AAUD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,EACnC,IAAI,GAAE,oBAAyB,GAC9B,OAAO,CAAC,sBAAsB,CAAC,CAqBjC;AAID,UAAU,iBAAiB;IACzB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,iGAAiG;AACjG,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAYrE;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAID,8CAA8C;AAC9C,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD;AAED,wGAAwG;AACxG,wBAAsB,qBAAqB,CAAC,IAAI,GAAE,mBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA6B3F"}