gogcli-mcp 2.20.0 → 2.21.1

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.
@@ -0,0 +1,147 @@
1
+ import { redactSecrets } from './runner.js';
2
+
3
+ /**
4
+ * One line per auth-state transition, on the paths where a Google credential is
5
+ * minted, served, evicted, replaced or refused.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * The incident that produced this branch could not be investigated. `gog`
10
+ * connectors told a user all session to re-authorize a Google account whose
11
+ * grant was healthy, and nothing anywhere had recorded a single auth-state
12
+ * transition: not the runner's transport 401, not a mint, not a cache hit, not
13
+ * a dead grant. `wrangler.jsonc` has set `observability.enabled = true` since
14
+ * the Worker shipped, so a Workers Logs sink was live the whole time with
15
+ * nothing writing to it.
16
+ *
17
+ * The other three fixes on this branch made those outcomes DISTINGUISHABLE —
18
+ * a transport 401 is now a typed RunnerTransportError, a rejected access token
19
+ * is evicted and re-minted, `invalid_grant` is separated from a token that
20
+ * merely expired. This one makes them VISIBLE, which is what turns "the user
21
+ * says it was broken yesterday" into a query.
22
+ *
23
+ * ## Why console, and why never console.log
24
+ *
25
+ * Workers Logs captures `console.*` and nothing else — there is no other sink
26
+ * available to a Worker without adding a dependency and a network hop to the
27
+ * request path.
28
+ *
29
+ * But this module also loads in the stdio servers (`useRemoteGogRunner` wires
30
+ * the same executor and the same token source into a plain Node process), and
31
+ * there STDOUT IS THE JSON-RPC CHANNEL. Node routes `console.log`, `.info`,
32
+ * `.debug` and `.trace` to fd 1, so any one of them would interleave a log line
33
+ * with the protocol frames and break the session. Only `.warn` and `.error` go
34
+ * to fd 2. That is the whole reason routine transitions are emitted at `warn`
35
+ * rather than at `info` where their severity belongs: `warn` is the least-severe
36
+ * console method that Node does not send down the wire. The record carries its
37
+ * own `event` name, so a log consumer classifies on that rather than on level.
38
+ *
39
+ * ## Why the whole line is redacted
40
+ *
41
+ * `reason` is built from text this layer did not author — gog's stderr, Google's
42
+ * error body, a rejected fetch's message — and any of those can quote a token
43
+ * verbatim. Redacting per-field would leave the next field someone adds
44
+ * unprotected, so the SERIALIZED record goes through the repo's existing
45
+ * `redactSecrets` (the shared mcp-utils redactor plus this repo's Google
46
+ * `ya29.…` / `1//…` shapes) as one string. A credential can therefore only
47
+ * appear in a log line if it survives the same redactor that guards every error
48
+ * this repo returns to a client.
49
+ *
50
+ * Credentials are never IDENTIFIED by value either: `credentialTag` derives the
51
+ * identifier from the SHA-256 that already keys the token cache, which is the
52
+ * hash-keying precedent google-token.ts set for exactly this reason.
53
+ */
54
+
55
+ /**
56
+ * What changed. Named after the transition rather than after the code site, so
57
+ * a query reads as a story about a credential rather than about a call stack.
58
+ */
59
+ export type AuthTransition =
60
+ /** A fresh access token was obtained from the refresh token. */
61
+ | 'token.minted'
62
+ /** An unexpired cached access token was served without contacting Google. */
63
+ | 'token.cache-hit'
64
+ /** The exchange failed for a reason that is not a dead grant. */
65
+ | 'token.mint-failed'
66
+ /** A rejected access token was dropped, so the next read mints a new one. */
67
+ | 'token.evicted'
68
+ /** Nothing was dropped: the credential holds no token, or a different one. */
69
+ | 'token.evict-noop'
70
+ /** The REFRESH token is gone. Nothing can be minted; a human must re-enrol. */
71
+ | 'grant.dead'
72
+ /** Google refused our token, but this call is not one we may replay. */
73
+ | 'replay.declined'
74
+ /** Replaying the call once with a freshly minted token. */
75
+ | 'replay.attempted'
76
+ /** The replay succeeded — the caller saw no error at all. */
77
+ | 'replay.succeeded'
78
+ /** The replay failed too; the original failure reaches the caller. */
79
+ | 'replay.failed'
80
+ /** The RUNNER rejected our bearer. gog never ran; no Google credential read. */
81
+ | 'runner.auth-failed';
82
+
83
+ /**
84
+ * Which credential, which service, which backend, and why — every field
85
+ * optional because each call site honestly knows a different subset. A
86
+ * transport 401 has no credential to name; a mint has no service.
87
+ */
88
+ export interface AuthContext {
89
+ /** `credentialTag` of the hash that keys the token cache. Never a token. */
90
+ credential?: string;
91
+ /** The `gog` service word (gmail, drive, sheets…), when there is one. */
92
+ service?: string;
93
+ /** The gog-runner base URL. Configuration, not a secret. */
94
+ endpoint?: string;
95
+ /** Prose. Redacted with everything else — see the module note. */
96
+ reason?: string;
97
+ }
98
+
99
+ /**
100
+ * Transitions that describe something going WRONG, routed to `console.error`.
101
+ * Everything else is routine and goes to `console.warn` (see the module note on
102
+ * why not `console.info`).
103
+ *
104
+ * `token.evicted` is deliberately NOT here: an eviction is the repair working.
105
+ * `replay.declined` is not either — declining is usually the correct, safe
106
+ * answer (a write, a superseded token), and only reads as a failure alongside
107
+ * the record that follows it.
108
+ */
109
+ const FAILURES: ReadonlySet<AuthTransition> = new Set<AuthTransition>([
110
+ 'token.mint-failed',
111
+ 'grant.dead',
112
+ 'replay.failed',
113
+ 'runner.auth-failed',
114
+ ]);
115
+
116
+ /** Marker so one `grep gog-auth` finds every record and nothing else. */
117
+ const PREFIX = 'gog-auth ';
118
+
119
+ /**
120
+ * How many hex characters of the cache key identify a credential in a log.
121
+ *
122
+ * 12 hex characters is 48 bits — far past collision risk for the handful of
123
+ * credentials one deployment holds, and short enough to read across lines. The
124
+ * input is already a SHA-256 of (clientId, refreshToken), so truncating it can
125
+ * only ever remove information.
126
+ */
127
+ const TAG_CHARS = 12;
128
+
129
+ /** The log-safe name for a credential, from the hash that already keys it. */
130
+ export function credentialTag(cacheKeyHash: string): string {
131
+ return cacheKeyHash.slice(0, TAG_CHARS);
132
+ }
133
+
134
+ /**
135
+ * Emit one record. Deliberately synchronous, allocation-light and
136
+ * exception-free by construction: this sits on the request path, and an
137
+ * observability call that can fail or block would be worse than the silence it
138
+ * replaces.
139
+ */
140
+ export function logAuthTransition(event: AuthTransition, context: AuthContext): void {
141
+ // `at` and `event` first so the line reads left to right; JSON.stringify
142
+ // drops the context keys whose value is undefined, which is why an absent
143
+ // credential produces no `"credential":null` noise.
144
+ const record = JSON.stringify({ at: new Date().toISOString(), event, ...context });
145
+ const write = FAILURES.has(event) ? console.error : console.warn;
146
+ write(PREFIX + redactSecrets(record));
147
+ }