@rubytech/create-maxy-code 0.1.473 → 0.1.475

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 (119) hide show
  1. package/dist/__tests__/google-client-id-env.test.js +3 -2
  2. package/package.json +1 -1
  3. package/payload/platform/neo4j/schema.cypher +4 -1
  4. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +29 -16
  5. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  6. package/payload/platform/plugins/docs/references/google-guide.md +25 -12
  7. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +2 -2
  8. package/payload/platform/plugins/google/.claude-plugin/plugin.json +1 -1
  9. package/payload/platform/plugins/google/PLUGIN.md +73 -14
  10. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.d.ts +2 -0
  11. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.d.ts.map +1 -0
  12. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.js +229 -0
  13. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.js.map +1 -0
  14. package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js +9 -9
  15. package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js.map +1 -1
  16. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.d.ts +2 -0
  17. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.d.ts.map +1 -0
  18. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.js +17 -0
  19. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.js.map +1 -0
  20. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.d.ts +2 -0
  21. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.d.ts.map +1 -0
  22. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.js +146 -0
  23. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.js.map +1 -0
  24. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.d.ts +2 -0
  25. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.js +19 -0
  27. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.js.map +1 -0
  28. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.d.ts +2 -0
  29. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.d.ts.map +1 -0
  30. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.js +41 -0
  31. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.js.map +1 -0
  32. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.d.ts +44 -0
  33. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.d.ts.map +1 -0
  34. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.js +112 -0
  35. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.js.map +1 -0
  36. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.d.ts +17 -0
  37. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.d.ts.map +1 -0
  38. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.js +143 -0
  39. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.js.map +1 -0
  40. package/payload/platform/plugins/google/mcp/dist/auth/pending-store.d.ts +9 -0
  41. package/payload/platform/plugins/google/mcp/dist/auth/pending-store.d.ts.map +1 -1
  42. package/payload/platform/plugins/google/mcp/dist/auth/pending-store.js.map +1 -1
  43. package/payload/platform/plugins/google/mcp/dist/index.js +73 -5
  44. package/payload/platform/plugins/google/mcp/dist/index.js.map +1 -1
  45. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.d.ts +2 -0
  46. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.d.ts.map +1 -0
  47. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.js +37 -0
  48. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.js.map +1 -0
  49. package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts +20 -4
  50. package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts.map +1 -1
  51. package/payload/platform/plugins/google/mcp/dist/tools/account-register.js +19 -9
  52. package/payload/platform/plugins/google/mcp/dist/tools/account-register.js.map +1 -1
  53. package/payload/platform/plugins/google/references/auth.md +331 -367
  54. package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
  55. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +3 -3
  56. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  57. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.d.ts +14 -1
  58. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  59. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.js +15 -0
  60. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.js.map +1 -1
  61. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  62. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +3 -1
  63. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  64. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +51 -4
  65. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +4 -0
  66. package/payload/platform/scripts/backfill-task-ids.sh +206 -0
  67. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +21 -3
  68. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  69. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +6 -2
  70. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  71. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +43 -5
  73. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +9 -0
  75. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +78 -0
  77. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  78. package/payload/server/public/activity.html +6 -6
  79. package/payload/server/public/assets/{AdminLoginScreens-B7TQZV8u.js → AdminLoginScreens-B44KbEuC.js} +1 -1
  80. package/payload/server/public/assets/{AdminShell-DAuri4Yh.js → AdminShell-BjxV2qkb.js} +2 -2
  81. package/payload/server/public/assets/{Checkbox-B1XhnhPk.js → Checkbox-DSeZNGNA.js} +1 -1
  82. package/payload/server/public/assets/activity-Mz1BhgB1.js +1 -0
  83. package/payload/server/public/assets/{admin-BdcccSb5.js → admin-B4LpTCur.js} +1 -1
  84. package/payload/server/public/assets/{browser-B6woQ2qX.js → browser-CEFvaWwI.js} +1 -1
  85. package/payload/server/public/assets/{calendar-CalCeg5T.js → calendar-B3zo6V34.js} +1 -1
  86. package/payload/server/public/assets/chat-5tJdo9i_.js +1 -0
  87. package/payload/server/public/assets/chevron-left-BJllrPBj.js +1 -0
  88. package/payload/server/public/assets/data-Du79EgR-.js +1 -0
  89. package/payload/server/public/assets/{graph-HeZaatSQ.js → graph-RaOrnSf2.js} +1 -1
  90. package/payload/server/public/assets/{graph-labels-4W7JuOgG.js → graph-labels-GlTEIYJj.js} +1 -1
  91. package/payload/server/public/assets/{maximize-2-DKx-aTPb.js → maximize-2-CHDa8p5h.js} +1 -1
  92. package/payload/server/public/assets/{operator-DD9L2VJM.js → operator-DtqHY4jV.js} +1 -1
  93. package/payload/server/public/assets/{page-tJxw0zNO.js → page-CkhFj8GQ.js} +1 -1
  94. package/payload/server/public/assets/{page-Ceado9Kr.js → page-DxnWgPtd.js} +1 -1
  95. package/payload/server/public/assets/{public-BYFWxyeC.js → public-D5TUfQ3I.js} +1 -1
  96. package/payload/server/public/assets/{rotate-ccw-DrzdV9h7.js → rotate-ccw-BJjVAQQY.js} +1 -1
  97. package/payload/server/public/assets/tasks-CM9pYYG1.js +1 -0
  98. package/payload/server/public/assets/{time-entry-format-Bn79w6tq.js → time-entry-format-C5RYEYjM.js} +1 -1
  99. package/payload/server/public/assets/{triangle-alert-CjDVh6r-.js → triangle-alert-DLfIsuo_.js} +1 -1
  100. package/payload/server/public/assets/{useCopyFeedback-DXHkkJw-.js → useCopyFeedback-7yKeiAzV.js} +1 -1
  101. package/payload/server/public/assets/{useSelectionMode-D2H8jAhE.js → useSelectionMode-CWlh6AEq.js} +1 -1
  102. package/payload/server/public/assets/{useSubAccountSwitcher-Br7Z6Cpu.css → useSubAccountSwitcher-B2n9QPVI.css} +1 -1
  103. package/payload/server/public/assets/{useVoiceRecorder-zUvPe55I.js → useVoiceRecorder-j3ZArZ2H.js} +1 -1
  104. package/payload/server/public/browser.html +5 -5
  105. package/payload/server/public/calendar.html +6 -6
  106. package/payload/server/public/chat.html +12 -12
  107. package/payload/server/public/data.html +11 -11
  108. package/payload/server/public/graph.html +11 -11
  109. package/payload/server/public/index.html +14 -14
  110. package/payload/server/public/operator.html +14 -14
  111. package/payload/server/public/public.html +12 -12
  112. package/payload/server/public/tasks.html +5 -5
  113. package/payload/server/server.js +433 -313
  114. package/payload/server/public/assets/activity-kRwzxEKU.js +0 -1
  115. package/payload/server/public/assets/chat-9s2-L06v.js +0 -1
  116. package/payload/server/public/assets/chevron-left-C3ho8SnB.js +0 -1
  117. package/payload/server/public/assets/data-Cfr-7ERI.js +0 -1
  118. package/payload/server/public/assets/tasks-DSwoxGzh.js +0 -1
  119. /package/payload/server/public/assets/{useSubAccountSwitcher-CUJpgkKI.js → useSubAccountSwitcher-CBlKgwms.js} +0 -0
@@ -1,467 +1,431 @@
1
1
  # Google Auth Reference
2
2
 
3
- ## Status: Phase 0 gate CLOSED token-verified 2026-07-18
3
+ The `google` plugin connects one Google Calendar per registered Google account,
4
+ using a **Desktop (installed app)** OAuth client whose id and secret ship in
5
+ brand config. This file is the decision record and the vendor runbook.
4
6
 
5
- The round-trip was run against the live `sitedesk-code` client and succeeded. Recorded
6
- outcome (values themselves deliberately not recorded):
7
-
8
- | Check | Result |
9
- |---|---|
10
- | Calendar scope granted via browser consent | **yes** — `scope=https://www.googleapis.com/auth/calendar` returned on the redirect and echoed in the token response |
11
- | Refresh token issued (`access_type=offline`, `prompt=consent`) | **yes** — `refresh_token` present |
12
- | Access-token lifetime | `expires_in: 3599` (1 hour) |
13
- | Refresh-token lifetime in **Testing** | `refresh_token_expires_in: 604799` — **6.99 days**, confirming the predicted 7-day Testing cap empirically |
14
- | Device-code refusal (Verdict 1) | not run empirically; rests on Google's published allowlist. Optional, changes no decision (see the live-test section) |
15
-
16
- The 604799 figure is the concrete argument for completing brand verification: in Testing,
17
- every connected account silently re-consents weekly. Verification removes it.
18
-
19
- The flow below is the ORIGINAL Phase 0 verdict and is retained for its evidence.
20
- It has since been superseded on two points: the vendor app is a
21
- Desktop client whose secret ships in brand config, and the redirect is loopback
22
- rather than a hosted HTTPS callback. Read the Desktop sections further down as
23
- authoritative where the two differ.
24
-
25
- ## Original spike verdict (documentation-backed, now confirmed)
26
-
27
- This is the Phase 0 deliverable: the OAuth-flow verdict for a per-account
28
- **Google Calendar** plugin on a headless install where the operator consents from a phone
29
- over WhatsApp, with no browser on the box.
30
-
31
- Scope note: this plugin is **calendar-only**. Mail stays on the `email` plugin's IMAP/SMTP
32
- path; there is no Gmail API surface. That choice is deliberate and it is what keeps this
33
- plugin out of Google's restricted-scope security review (see
34
- ["Verification"](#verification-the-light-kind-not-the-security-assessment)). Contacts
35
- (People API, read-only) is a possible later addition on the same footing but is not in this
36
- plugin.
37
-
38
- Every claim below is grounded in Google's **published** behaviour, cited inline. What is
39
- **not** yet done is the live token test the task requires: a real token issued (or refused)
40
- by an actual Google OAuth client for the Calendar scope. Until that runs
41
- (["Live test that closes the Phase 0 gate"](#live-test-that-closes-the-phase-0-gate)),
42
- this document is a prediction, not a proof. Do **not** build the register/register-poll
43
- path against it before the live test confirms the verdict.
44
-
45
- What is decided from published behaviour:
46
-
47
- - The **device-authorization grant is rejected** for this plugin. Google's device flow
48
- serves only a fixed allowlist of scopes; Calendar is not on it.
49
- - The flow is therefore an **operator-assisted authorization-code (PKCE) flow**, and it
50
- needs a redirect method, because Google's out-of-band ("paste the code") flow is fully
51
- blocked.
52
- - The redirect method recommended at the time was a hosted HTTPS callback on the
53
- brand's Cloudflare tunnel domain (a "Web application" client). **Superseded by
54
- the recent decision**: the fallback named here — a Desktop client with a loopback redirect
55
- and a manual code relay — is what shipped, because only an installed-app secret
56
- can be distributed to customer devices.
57
- - **Verification is the light kind.** Calendar is a *sensitive* scope, not a *restricted*
58
- one, so a brand's app needs only OAuth brand/consent-screen verification, never the
59
- annual third-party security assessment.
60
-
61
- This diverges from Outlook on one axis: Outlook's device-code flow is secret-less, and the
62
- recommended Google flow holds a per-brand client secret. That divergence is forced by
63
- Google, not chosen; the reasoning is below.
7
+ Decided 2026-07-18, superseding the earlier hosted-HTTPS-callback model. That model is gone from the tree; see
8
+ ["What was removed"](#what-was-removed) for what to expect on an old device.
64
9
 
65
10
  ---
66
11
 
67
- ## Verdict 1: device-code is rejected (scope allowlist)
68
-
69
- Outlook uses Microsoft's `/devicecode` grant so the operator enters a code on their own
70
- phone and never needs a browser on the box. The first question for Google was whether the
71
- same grant works.
72
-
73
- It does not, for the Calendar scope. Google's OAuth 2.0 flow for TV and limited-input
74
- devices is supported only for a fixed allowlist. Verbatim from Google's documentation
75
- ([OAuth 2.0 for TV and Limited-Input Device Applications](https://developers.google.com/identity/protocols/oauth2/limited-input-device)):
76
-
77
- > The OAuth 2.0 flow for devices is supported only for the following scopes:
78
- >
79
- > **OpenID Connect, Google Sign-In:** `email`, `openid`, `profile`
80
- > **Drive API:** `https://www.googleapis.com/auth/drive.appdata`, `https://www.googleapis.com/auth/drive.file`
81
- > **YouTube API:** `https://www.googleapis.com/auth/youtube`, `https://www.googleapis.com/auth/youtube.readonly`
12
+ ## Vendor runbook: creating the client (one-time per brand)
13
+
14
+ Not an end-user step. Run once per brand; each brand is a separate provisioning
15
+ follow-up, mirroring Outlook 1479/1485.
16
+
17
+ 1. Sign in to the [Google Cloud console](https://console.cloud.google.com) with
18
+ the brand's Google account and select the brand's **existing** project not
19
+ a new one, so the consent screen and any verification progress carry over.
20
+ For `sitedesk-code` that project is `Sitedesk Calendar`.
21
+ 2. Enable the **Google Calendar API** (APIs & Services → Library). It is the
22
+ only API this plugin calls.
23
+ 3. Configure the **OAuth consent screen**: user type **External**, app name and
24
+ support email set to the brand's, and the single scope from
25
+ ["Scope"](#scope) below. Publishing status starts at **Testing**.
26
+ 4. **Add test users** while the app is in Testing. Consent is refused for anyone
27
+ not on that list, including the operator, and the refusal renders as an
28
+ access-denied screen that reads like a flow bug.
29
+ 5. **Credentials → Create credentials → OAuth client ID**, application type
30
+ **Desktop app**, named for the brand. Register redirect `http://127.0.0.1`.
31
+ 6. Copy the **Client ID** and **Client secret** into that brand's `brand.json`:
82
32
 
83
- The Calendar scope (`https://www.googleapis.com/auth/calendar`) is absent. A device-flow
84
- authorization request that includes it is refused. The device-code grant cannot represent
85
- this plugin's calendar.
33
+ ```jsonc
34
+ // brands/<brand>/brand.json
35
+ "googleClientId": "<the client id>",
36
+ "googleClientSecret": "<the client secret>"
37
+ ```
86
38
 
87
- **The live test still owes the verbatim refusal.** The task's gate is a real refusal from a
88
- real client, not this allowlist. The live test below issues a `/device/code` request for
89
- the Calendar scope against a real client of type "TV and Limited Input" and records the
90
- exact error body. Expected: a `403`/`invalid_scope`-class refusal naming the scope. The
91
- allowlist above is the reason to expect it; the recorded response is the proof.
39
+ 7. **Run the loopback pre-flight below before publishing.** It is the only step
40
+ that catches a wrong client type, and it costs seconds.
41
+ 8. Re-bundle and publish the installer. No file is placed on any device by hand.
92
42
 
93
- ## Verdict 2: OOB ("paste a code") is dead — a redirect is mandatory
43
+ ### Pre-flight: confirm the client accepts loopback
94
44
 
95
- The other headless-friendly pattern is out-of-band: Google shows the operator a code on a
96
- web page, the operator pastes it back, no redirect URI. That flow is gone. Google blocked
97
- new OOB use on 28 Feb 2022 and blocked all existing clients on 31 Jan 2023
98
- ([OOB migration guide](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration);
99
- [Google Developers Blog](https://developers.googleblog.com/making-google-oauth-interactions-safer-by-using-more-secure-oauth-flows/)).
100
- A `redirect_uri=urn:ietf:wg:oauth:2.0:oob` request is refused.
45
+ The failure this catches is a **Web** client pasted in where a Desktop one
46
+ belongs. Nothing else in the chain notices: the brand bundles, the installer
47
+ stamps, the plugin boots reporting both values configured, and the consent URL
48
+ builds. The first symptom is a customer completing consent and hitting
49
+ `redirect_uri_mismatch`, which reads as an expired code rather than a wrong
50
+ client type.
101
51
 
102
- So consent must land on a real `redirect_uri`. For an installed/desktop app Google's named
103
- replacement is the **loopback IP redirect** (`http://127.0.0.1:<port>` or
104
- `http://localhost:<port>`), and it "requires you to be listening on a local web server
105
- ... to receive the authorization code" (OOB migration guide). Google's migration guidance
106
- covers mobile, Chrome, web, and desktop clients; it gives **no** cross-device pattern for
107
- a headless box where consent happens on a separate phone. That gap is the design problem
108
- this plugin has to solve itself.
52
+ The discriminator is that a genuine Desktop client accepts a loopback redirect
53
+ **without any redirect being registered on it**, because RFC 8252 §7.3 makes the
54
+ port irrelevant for the `127.0.0.1` literal. A client that refuses loopback is
55
+ therefore not a usable Desktop client, whatever the console labels it.
109
56
 
110
- ## Verdict 3: the flow operator-assisted authorization-code (PKCE)
57
+ Sends the client id only, never the secret. Non-mutating.
111
58
 
112
- Because the operator's browser is on a phone and the plugin is on the box, the redirect
113
- that carries the `code` and the process that exchanges it live on different machines. Two
114
- concrete flows bridge that. The recommendation is Option B primary, Option A as the
115
- secret-less fallback.
59
+ ```bash
60
+ CID=$(node -e 'console.log(JSON.parse(require("fs").readFileSync("brands/<brand>/brand.json","utf8")).googleClientId.trim())')
61
+ curl -s -D - -o /dev/null \
62
+ "https://accounts.google.com/o/oauth2/v2/auth?client_id=${CID}&redirect_uri=http%3A%2F%2F127.0.0.1%3A8571&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar" \
63
+ | grep -i '^location:' | grep -q 'authError' && echo REJECTED || echo ACCEPTED
64
+ ```
116
65
 
117
- ### Option B (SUPERSEDED): hosted HTTPS callback on the brand tunnel domain
66
+ The two words report **what Google did with the redirect**, not whether you have
67
+ a problem. Which of them is the good news depends on which redirect you sent, so
68
+ read them against the table below rather than by tone.
118
69
 
119
- Client type **Web application**. A stable authorized redirect URI on the install's existing
120
- Cloudflare **public** host, served by the always-running `platform/ui` server (the same
121
- process that runs the Outlook auto-poll dispatcher):
70
+ `ACCEPTED` means Google took the redirect and moved the browser to the sign-in
71
+ flow. `REJECTED` means it bounced to an error page; decode the `authError`
72
+ payload to see which error:
122
73
 
123
- ```
124
- https://<brand-public-host>/api/google/oauth/callback
74
+ ```bash
75
+ # ...append to the curl above instead of the grep chain
76
+ | sed -E 's/.*authError=([^&]*).*/\1/' | tr '_-' '/+' | base64 -d | head -c 200
125
77
  ```
126
78
 
127
- **The `/api/` prefix is mandatory, not cosmetic.** The public host is not a general web
128
- server: it admits only an allowlist of path prefixes and 404s everything else
129
- (`platform/ui/server/public-host-gate.ts`, `PUBLIC_ALLOWED_PREFIXES`). A bare
130
- `/google/oauth/callback` would 404 before reaching any route. Phase 2 must add
131
- `/api/google/` to that allowlist in the same commit as the callback route.
79
+ - `redirect_uri_mismatch` the client will not take a loopback redirect. It is
80
+ a Web client, or a Desktop client whose configuration was altered. Create a
81
+ **Desktop app** client and use that pair.
82
+ - `invalid_request` the redirect in the probe itself is malformed. Check the
83
+ URL-encoding rather than the client.
132
84
 
133
- The public host is also the correct *class* of host, and there is an exact in-repo
134
- precedent. QuickBooks already solved this identical problem, and the allowlist records why:
85
+ **Both outcomes return HTTP 302**, so the status code is not the signal and a
86
+ probe that checks it reports success on a broken client. The `authError`
87
+ parameter is the signal.
135
88
 
136
- > QuickBooks OAuth consent callback. Intuit redirects the operator's browser here with no
137
- > admin cookie; the route authenticates by validating the OAuth `state` nonce, not the
138
- > remote-auth gate (same model as `/api/telegram/`).
89
+ Run the probe **twice**, and read the pair:
139
90
 
140
- Google's redirect has the same shape: the client's phone browser arrives with no admin
141
- cookie, so the route authenticates on the `state` nonce rather than the remote-auth gate.
142
- Mirror `/api/quickbooks/` rather than inventing a new pattern.
91
+ | Run | Redirect sent | Required result | What it establishes |
92
+ |---|---|---|---|
93
+ | Subject | `http://127.0.0.1:8571` | `ACCEPTED` | The client takes loopback, so it is a usable Desktop client |
94
+ | Control | `https://example.invalid/cb` | `REJECTED` (`invalid_request`) | The probe can still detect a rejection |
143
95
 
144
- ```
145
- operator (phone) plugin / platform-ui (box) Google
146
- | | |
147
- | google-account-register | |
148
- |--------------------------------------->| build PKCE (verifier+challenge), |
149
- | | state; write pending-oauth.enc |
150
- | | build consent URL (access_type= |
151
- | | offline, prompt=consent, calendar |
152
- | | scope, redirect=<tunnel>/cb) |
153
- | { status:"pending", consentUrl } | |
154
- |<---------------------------------------| |
155
- | open consentUrl on phone, sign in, | |
156
- | consent -------------------------------------------------------------------> |
157
- | | GET /api/google/oauth/callback?code&state <-- redirect
158
- | | verify state, exchange code+verifier |
159
- | | (+client secret) ---------------> |
160
- | | < tokens (access+refresh) |
161
- | | GET userinfo → account email/id |
162
- | | encrypt → accounts/<id>/tokens.enc |
163
- | | clear pending |
164
- | google-account-register-poll → { registered, email } |
165
- |<---------------------------------------| |
166
- ```
96
+ `REJECTED` on the control is the **expected and required** outcome — it is not a
97
+ fault, and nothing about the client needs changing when you see it. Its only job
98
+ is to prove the probe is still measuring something.
167
99
 
168
- The operator sees a clean "connected, return to WhatsApp" page; the code is captured
169
- server-side, never relayed by hand. `register-poll` reports success from local state,
170
- exactly like Outlook's poll. This is the better ergonomic match for a non-technical
171
- operator on WhatsApp, which is the whole reason device-code was wanted.
100
+ The failure mode it guards against is a probe that has silently stopped
101
+ measuring: if the `grep` chain breaks Google renames the error parameter, or
102
+ stops emitting a `location:` header the command still exits cleanly and still
103
+ prints `ACCEPTED`. Silent green. `ACCEPTED` on **both** runs means the probe is
104
+ dead and the subject run proves nothing; fix the probe before trusting it.
172
105
 
173
- Cost: a Web-application client carries a **real client secret** and a per-brand authorized
174
- redirect URI. The secret is confidential and lives in the brand/account secret store, never
175
- bundled into the installer. This is the one divergence from Outlook.
106
+ Never act on the control run alone. Reading its `REJECTED` as a verdict on the
107
+ client would send you to replace a credential that is working.
176
108
 
177
- ### Option A (fallback): loopback client + manual code relay
109
+ Trailing whitespace on a pasted id is a real and silent failure; nothing in the
110
+ chain trims it. The `.trim()` above is for the probe only, so the probe can pass
111
+ while the shipped value is broken. Confirm the stored value is clean:
178
112
 
179
- Client type **Desktop app**, redirect `http://127.0.0.1:<port>`. No hosted endpoint, no
180
- per-brand redirect URI beyond the fixed loopback, and the installed-app secret Google
181
- issues for desktop clients is documented as non-confidential.
113
+ ```bash
114
+ node -e 'const v=JSON.parse(require("fs").readFileSync("brands/<brand>/brand.json","utf8")).googleClientId; console.log(v===v.trim()?"clean":"WHITESPACE")'
115
+ ```
182
116
 
183
- The operator opens the consent URL on the phone and consents; Google redirects the phone
184
- browser to `http://localhost:<port>/?code=...`, which fails to load (nothing listens on the
185
- phone). The operator copies the `code` value out of the address bar of the failed page and
186
- sends it back via a `google-account-register-submit` tool; the plugin exchanges it with the
187
- stored PKCE verifier.
117
+ **The client secret is shown exactly once.** The console displays only the last
118
+ four characters afterwards ("Viewing and downloading client secrets is no longer
119
+ available"). Capture it at creation straight into `brand.json`, or plan to
120
+ rotate rotation is free and has no downtime: **Add secret**, store the new
121
+ one, delete the old. Never paste it into a chat transcript or a shell command
122
+ that lands in history.
188
123
 
189
- Cost: extracting a `code` from a failed-load address bar on a phone is fragile — some mobile
190
- browsers hide the URL of a failed navigation. Use this only where a brand has no usable
191
- hosted callback. The live test measures whether this UX is acceptable before it is relied on.
124
+ Confirm the stamp took from the boot line, which reports presence and never
125
+ values:
126
+
127
+ ```bash
128
+ grep -m1 'google-mcp. boot' ~/.<brand>-code/logs/server.log
129
+ # ... clientIdConfigured=true clientSecretConfigured=true
130
+ ```
192
131
 
193
- ### Why the recommendation, and what the live test decides
132
+ Either reading `false` on a brand that ships both is the mis-stamp signature.
194
133
 
195
- Option B wins on operator UX and robustness at the cost of a per-brand secret. Option A
196
- keeps provisioning minimal and secret-light at the cost of a fragile mobile step. The live
197
- test resolves the open question that Option B's hosted callback round-trips a real token
198
- for the Calendar scope before any register code is built. If the tunnel-hosted callback
199
- is not viable for a given brand, Option A is the recorded fallback.
134
+ ### Connecting an account, once the brand ships a client
135
+
136
+ `google-account-register` returns a consent URL. The user opens it on any device
137
+ and approves. What happens next depends on **where** they approved, and both
138
+ paths are supported.
139
+
140
+ **On the install's own machine.** Register binds a single-shot listener on
141
+ `127.0.0.1` at an OS-assigned port and puts that port in the consent URL, so
142
+ Google's redirect reaches it. The listener verifies `state`, completes the
143
+ exchange in-process, and renders a page naming the connected account. Nothing is
144
+ pasted. The page asks the user to return to the chat and say so, because the
145
+ agent has no callback and learns the outcome from
146
+ `google-account-register-poll`.
147
+
148
+ **On another device.** A phone cannot route to the install's loopback, so the
149
+ redirect fails to load exactly as before. The user copies the whole address-bar
150
+ URL and passes it to `google-account-register-submit`, which exchanges it
151
+ in-process. This path is not a degraded mode; it is the designed route for the
152
+ Pi deployment, where the admin always consents from a phone.
153
+
154
+ That failed-page step remains a real UX cost on the cross-device path, priced
155
+ and accepted: some mobile browsers hide the URL of a failed navigation. It is
156
+ the price of a client type whose secret can legitimately ship to customer
157
+ devices, and it cannot be removed — the phone has no route to the install.
158
+
159
+ **The port is no longer fixed**, and the consent URL is no longer deterministic.
160
+ RFC 8252 §7.3 is what permits this: Google ignores the port when matching a
161
+ loopback redirect, so binding `:0` is free. It also retires a latent collision,
162
+ since a fixed port is shared by every brand and two co-resident brands would
163
+ contend for it. `http://127.0.0.1:8571` survives as the fallback when the bind
164
+ fails, which keeps an install that cannot bind working exactly as it did before.
165
+
166
+ Because the port varies per registration, the pending entry stores the redirect
167
+ it issued and the exchange sends that stored value back. Google compares the two
168
+ byte for byte, so a registration whose listener bound a port cannot be completed
169
+ against the constant.
200
170
 
201
171
  ---
202
172
 
203
- ## Client-id / secret model per brand
173
+ ## Why this shape
204
174
 
205
- One vendor-owned Google Cloud OAuth client per brand serves every install and every account
206
- on that brand, exactly as `outlookClientId` does. The client id ships as the brand default
207
- `brands/<brand>/brand.json#googleClientId`, stamped onto the session-manager unit env as
208
- `GOOGLE_CLIENT_ID` and inherited down the spawn chain (manager unit env → PTY → MCP child).
175
+ ### Device-code is rejected (scope allowlist)
209
176
 
210
- Divergence from Outlook: the recommended Web-application flow (Option B) also needs a
211
- **client secret**, which ships in `brand.json` (the house-only file this
212
- once described is withdrawn). A brand that ships `googleClientId` empty has the plugin inert with an
213
- actionable signal (`clientIdConfigured=false`), register refuses, and no operator action
214
- makes it work until the vendor client exists — mirroring Outlook's inert behaviour.
177
+ Outlook uses Microsoft's `/devicecode` grant, so the operator never needs a
178
+ browser on the box. That grant does not work for Calendar. Google's flow for TV
179
+ and limited-input devices serves only a fixed allowlist. Verbatim from
180
+ [OAuth 2.0 for TV and Limited-Input Device Applications](https://developers.google.com/identity/protocols/oauth2/limited-input-device):
215
181
 
216
- ### The vendor app is a Desktop client, and its secret ships in brand config
182
+ > The OAuth 2.0 flow for devices is supported only for the following scopes:
183
+ >
184
+ > **OpenID Connect, Google Sign-In:** `email`, `openid`, `profile`
185
+ > **Drive API:** `https://www.googleapis.com/auth/drive.appdata`, `https://www.googleapis.com/auth/drive.file`
186
+ > **YouTube API:** `https://www.googleapis.com/auth/youtube`, `https://www.googleapis.com/auth/youtube.readonly`
217
187
 
218
- Decided 2026-07-18, superseding the house-only file this section
219
- used to describe. That model required placing a secret by hand on every install,
220
- which cannot reach a customer device and contradicts installer-only doctrine.
188
+ The Calendar scope is absent, so a device-flow request carrying it is refused.
189
+ The flow is therefore authorization-code + PKCE.
221
190
 
222
- There is no secret-free alternative: Google requires `client_secret` at the
223
- token endpoint for **every** client type, including Web clients using PKCE.
191
+ ### Out-of-band is dead, so a redirect is mandatory
224
192
 
225
- So the vendor app is a **Desktop (installed app)** client, because Google
226
- explicitly sanctions embedding that secret in distributed software — "the client
227
- secret is obviously not treated as a secret". `brand.json` therefore carries
228
- both values and the installer stamps them onto the session-manager unit:
193
+ Google blocked new out-of-band use on 28 Feb 2022 and all existing clients on
194
+ 31 Jan 2023 ([OOB migration guide](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration)).
195
+ A `redirect_uri=urn:ietf:wg:oauth:2.0:oob` request is refused. Consent must land
196
+ on a real redirect URI.
229
197
 
230
- ```jsonc
231
- // brands/<brand>/brand.json
232
- "googleClientId": "<the client id>",
233
- "googleClientSecret": "<the client secret>"
234
- ```
198
+ ### The secret cannot be avoided, so the client type must sanction shipping it
199
+
200
+ Google requires `client_secret` at the token endpoint for **every** client type,
201
+ including Web clients using PKCE. There is no public-client path, so any install
202
+ performing its own exchange holds one.
235
203
 
236
- They stamp **together or not at all**. A client id without its secret builds a
237
- consent URL the user can complete but an exchange that cannot, which fails later
238
- and less legibly than not offering the flow.
204
+ A Desktop client is the one type where Google explicitly sanctions embedding
205
+ that secret in distributed software "the client secret is obviously not
206
+ treated as a secret". `brand.json` is therefore a legitimate carrier, and the
207
+ installer stamps `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` **together or
208
+ not at all**. A client id without its secret builds a consent URL the user can
209
+ complete but an exchange that cannot, which fails later and less legibly than
210
+ not offering the flow.
239
211
 
240
212
  The secret ships to a **public** npm registry. That is the accepted trade: the
241
213
  residual risk is app impersonation and quota abuse against the brand's client
242
- id, not token theft, because PKCE still binds each code to its verifier.
214
+ id, **not** token theft, because PKCE still binds each code to its verifier.
243
215
 
244
216
  Rejected, each once and for a reason:
245
217
 
246
- - **Customer-owned apps** (the QuickBooks pattern, where the operator registers
247
- their own app). Unlike Intuit, Google demands app verification for the
248
- sensitive Calendar scope — homepage, privacy policy, domain ownership, demo
249
- video — which the target customer cannot produce.
250
- - **A vendor-hosted exchange broker**, where the install posts the code to a
251
- brand-operated endpoint. Best security and best UX, but it means operating a
252
- credential-bearing service for every customer.
218
+ - **Customer-owned apps** (the QuickBooks pattern at `quickbooks/PLUGIN.md:71`,
219
+ where the operator registers their own app). Unlike Intuit, Google demands app
220
+ verification for the sensitive Calendar scope — homepage, privacy policy,
221
+ domain ownership, demo video — which the target customer cannot produce.
222
+ - **A vendor-hosted exchange broker.** Best security and best UX, but it means
223
+ operating a credential-bearing service for every customer.
253
224
  - **A Web client with the secret shipped anyway.** A Web-app secret is genuinely
254
225
  confidential in Google's model; publishing it would be a real credential leak
255
226
  rather than a sanctioned embed.
227
+ - **A house-only secret file** (`platform/config/google-house.env`, the earlier
228
+ hosted-callback model). Requires a manual placement per install, so it cannot reach a
229
+ customer device and contradicts installer-only doctrine.
256
230
 
257
231
  ### The redirect is loopback, and the IP literal is mandatory
258
232
 
259
233
  Desktop clients permit only loopback redirects. `https` is rejected outright and
260
234
  custom URI schemes are withdrawn ("no longer supported due to the risk of app
261
- impersonation"), so this is the entire option space.
235
+ impersonation"), so this is the entire option space:
262
236
 
263
237
  ```
264
238
  http://127.0.0.1:8571
265
239
  ```
266
240
 
267
241
  **Never `localhost`.** RFC 8252 §7.3 makes the port irrelevant when matching a
268
- loopback redirect, but only for the IP literals `127.0.0.1` and `::1`; strict
269
- servers match the hostname literally. Register `http://127.0.0.1` on the client.
270
-
271
- Nothing listens on that port, by design. The consenting browser is the user's
272
- own phone, so the redirect fails to load and the user copies the code out of the
273
- address bar and pastes it back to `google-account-register-submit`. The port is
274
- fixed so consent URLs are deterministic and one register's logs compare with
275
- another's.
242
+ loopback redirect, but only for the IP literals `127.0.0.1` and `::1`; the
243
+ hostname is matched literally. Register `http://127.0.0.1` on the client.
276
244
 
277
- This is why the hosted HTTPS callback that the earlier plugin built is gone, along with
278
- its allowlist entry and its refresh route: a Desktop client cannot use it.
245
+ The port is fixed and arbitrary. Google ignores it for loopback and nothing
246
+ listens on it, so a constant keeps consent URLs deterministic and one register's
247
+ logs comparable with another's.
279
248
 
280
- ### Vendor runbook: creating the Desktop client
249
+ ---
281
250
 
282
- 1. In the **existing** Cloud project for the brand — not a new one, so the
283
- consent screen and any verification progress carry over — go to
284
- **Credentials → Create credentials → OAuth client ID**.
285
- 2. Application type **Desktop app**. Name it for the brand.
286
- 3. Copy the **Client ID** and **Client secret** into that brand's `brand.json`
287
- as `googleClientId` and `googleClientSecret`.
288
- 4. Re-bundle and publish. No file is placed on any device by hand.
251
+ ## Scope
289
252
 
290
- Confirm it took from the boot line, which reports presence and never values:
253
+ One scope, chosen to match the Outlook calendar tool surface (read plus create,
254
+ update, cancel, respond, free-busy), no broader:
291
255
 
292
- ```bash
293
- grep -m1 'google-mcp. boot' ~/.<brand>-code/logs/server.log
294
- # ... clientIdConfigured=true clientSecretConfigured=true
295
- ```
256
+ | Purpose | Scope | Class |
257
+ |---|---|---|
258
+ | Calendar read + event create/update/cancel/respond/free-busy | `https://www.googleapis.com/auth/calendar` | sensitive |
296
259
 
297
- Either reading `false` on a brand that ships both is the mis-stamp signature.
260
+ The account's email is read from `GET /calendars/primary` (whose id is the
261
+ address) rather than by requesting `openid`/`email`/`profile`, so consent stays
262
+ minimal.
298
263
 
299
- ---
264
+ A refresh token requires `access_type=offline` plus `prompt=consent` on the
265
+ consent URL; those are request parameters, not scopes.
300
266
 
301
- ## Vendor provisioning runbook (one-time per brand)
302
-
303
- Run once per brand to mint the client whose id goes in that brand's `brand.json` and whose
304
- secret goes in the brand secret store. Not an end-user step. Each brand is a separate
305
- follow-up once the plugin lands (mirroring Outlook 1479/1485).
306
-
307
- 1. Sign in to the [Google Cloud console](https://console.cloud.google.com) with the brand's
308
- Google account. Create (or select) one project named for the brand and its Google
309
- integration, e.g. `SiteDesk Google`.
310
- 2. Enable the **Google Calendar API** (APIs & Services → Library). That is the only API this
311
- plugin calls.
312
- 3. Configure the **OAuth consent screen**: user type **External**, app name and support
313
- email set to the brand's, and add the single scope below. Publishing status starts at
314
- **Testing** and moves to **In production** once brand verification is done (see below).
315
- 4. Create the **OAuth client**:
316
- - **Option B (recommended):** application type **Web application**. Add the authorized
317
- redirect URI `https://<brand-public-host>/api/google/oauth/callback` (the `/api/`
318
- prefix is required — see Option B above). Copy the **Client ID**
319
- (→ `brand.json#googleClientId`).
320
-
321
- **The client secret is shown exactly once and cannot be retrieved afterwards.** The
322
- console displays only the last four characters from then on ("Viewing and downloading
323
- client secrets is no longer available. If you have lost the secret below, please add a
324
- new one."). Capture it at creation into its final destination, or plan to rotate. It is
325
- never pasted into a chat transcript, a shell command that lands in history, or the repo.
326
- Rotation is free and has no downtime: **Add secret**, store the new one, delete the old.
327
- It ships in `brand.json` as `googleClientSecret` — see the Desktop sections above.
328
- - **Option A (fallback):** application type **Desktop app**. Copy the **Client ID**.
329
- 5. Set `googleClientId` in the brand's `brand.json` and re-bundle:
330
- ```jsonc
331
- // brands/<brand>/brand.json
332
- "googleClientId": "<the Client ID copied above>"
333
- ```
267
+ The narrower alternative is `calendar.events` + `calendar.readonly` +
268
+ `calendar.freebusy`. It has never been exercised. All Calendar scopes are
269
+ *sensitive*, not *restricted*, so narrowing would not change the verification
270
+ path.
334
271
 
335
- ### Scope
272
+ ## Verification: the light kind, not the security assessment
336
273
 
337
- One scope, chosen to match the Outlook calendar tool surface (read plus create, update,
338
- cancel, respond, free-busy), no broader:
274
+ Calendar is a **sensitive** scope, not a restricted one. A production app using
275
+ it needs only OAuth **brand/consent-screen verification** (own the domain;
276
+ Google reviews app name, logo, and scope justification). It does **not** need
277
+ the annual third-party security assessment, which applies only to *restricted*
278
+ scopes such as the Gmail read/modify family. Dropping the Gmail surface is
279
+ exactly what keeps a brand off that review — mail runs on the `email` plugin's
280
+ IMAP/SMTP path instead.
339
281
 
340
- | Purpose | Scope | Class |
341
- |---|---|---|
342
- | Identity (email address, sign-in) | `openid`, `email`, `profile` | non-sensitive |
343
- | Calendar read + event create/update/cancel/respond/free-busy | `https://www.googleapis.com/auth/calendar` | sensitive |
282
+ The two publishing states, and what each costs:
344
283
 
345
- Notes:
284
+ - **Testing** (no verification): works immediately for up to **100 named test
285
+ accounts**, each seeing the unverified-app warning and re-consenting about
286
+ every 7 days. Fine for a pilot.
287
+ - **In production + brand-verified** (no cost, no security firm, typically days
288
+ to a couple of weeks): the 100-account cap lifts, the warning is gone, and
289
+ refresh tokens are long-lived. This is the real deployment state and the
290
+ target for each brand. Start it early, because Google's review is the slow
291
+ part.
346
292
 
347
- - `calendar` (full) is the simplest choice for read plus all the control operations. The
348
- narrower alternative is `calendar.events` + `calendar.readonly` + `calendar.freebusy`; the
349
- live test can confirm the narrower set is sufficient before Phase 2 fixes it. All Calendar
350
- scopes are *sensitive*, not *restricted*, so the narrower choice does not change the
351
- verification path.
352
- - A refresh token requires `access_type=offline` plus `prompt=consent` on the consent URL;
353
- this is a request parameter, not a scope.
293
+ The 7-day Testing cap is measured, not predicted see below. It is Google
294
+ forcing re-consent until the brand is verified, **not** a refresh failure, and
295
+ the health signal names it distinctly.
354
296
 
355
- ### Verification: the light kind, not the security assessment
297
+ ### Recorded round-trip (2026-07-18)
356
298
 
357
- The Calendar scope is **sensitive**, not restricted. Google requires a production app that
358
- uses sensitive scopes to pass OAuth **brand/consent-screen verification** (own the domain,
359
- Google reviews the app name, logo, and scope justification). It does **not** require the
360
- annual third-party security assessment — that applies only to *restricted* scopes such as
361
- the Gmail read/modify family, which this plugin does not use. Dropping the Gmail surface is
362
- exactly what keeps a brand off the expensive review.
299
+ Run against the since-retired `sitedesk-code` Web client. The client type has
300
+ changed, but these facts are properties of the Calendar scope and the consent
301
+ screen, not of the client type, so they carry forward. Values deliberately not
302
+ recorded.
363
303
 
364
- The two publishing states, and what each costs:
304
+ | Check | Result |
305
+ |---|---|
306
+ | Calendar scope granted via browser consent | **yes** — `scope=https://www.googleapis.com/auth/calendar` returned on the redirect and echoed in the token response |
307
+ | Refresh token issued (`access_type=offline`, `prompt=consent`) | **yes** — `refresh_token` present |
308
+ | Access-token lifetime | `expires_in: 3599` (1 hour) |
309
+ | Refresh-token lifetime in **Testing** | `refresh_token_expires_in: 604799` — **6.99 days**, confirming the 7-day Testing cap empirically |
310
+ | Device-code refusal | not run empirically; rests on Google's published allowlist above. Changes no decision |
365
311
 
366
- - **Testing** (no verification): works immediately for up to **100 named test accounts**,
367
- each of which sees the unverified-app warning and re-consents about **every 7 days**
368
- (Google caps refresh-token lifetime while an app is in Testing). Fine for a pilot,
369
- including the first `sitedesk-code` account.
370
- - **In production + brand-verified** (the light verification, no cost, no security firm,
371
- typically days to a couple of weeks): the 100-account cap lifts, the warning is gone, and
372
- refresh tokens are long-lived. This is the real deployment state and the target for each
373
- brand. Start it early per brand, because Google's review is the slow part.
312
+ The 604799 figure is the concrete argument for completing brand verification: in
313
+ Testing, every connected account silently re-consents weekly.
374
314
 
375
315
  ---
376
316
 
377
- ## Token store and pending-state layout (informs Phase 1/2, not built here)
317
+ ## Token store and pending-state layout
378
318
 
379
- The store mirrors Outlook's per-account encrypted layout: one AES-256-CBC `.key` per
380
- account, `tokens.enc` under `accounts/<id>/`, mode 0700 dirs and 0600 files, atomic
381
- temp+rename writes. This plugin is one Google account per registration, so there is no
382
- multi-mailbox nesting to mirror.
319
+ Mirrors Outlook's per-account encrypted layout: one AES-256-CBC `.key` per
320
+ account, `tokens.enc` under `accounts/<id>/`, mode 0700 dirs and 0600 files,
321
+ atomic temp+rename writes.
383
322
 
384
323
  ```
385
324
  data/accounts/<accountId>/secrets/google/
386
325
  ├── .key # 32 random bytes, base64 — per-account AES-256-CBC key
387
326
  ├── accounts/
388
327
  │ └── <userId>/tokens.enc # AES-256-CBC token JSON blob per registered Google account
389
- └── pending-oauth.enc # transient: PKCE verifier + state between register and callback/submit
328
+ └── pending-oauth.enc # transient: PKCE verifier between register and submit
390
329
  ```
391
330
 
392
- Two divergences from Outlook are required and must be honoured in Phase 2:
331
+ Two divergences from Outlook are load-bearing:
393
332
 
394
- - **Pending state is a PKCE verifier + `state`, not a device code.** Register writes it;
395
- the callback (Option B) or `register-submit` (Option A) reads and clears it. Single-slot
396
- per account, refuses a second concurrent register with an `in-progress` signal, cleared
397
- on any terminal outcome.
398
- - **The concurrent-refresh race must not be reproduced.** Outlook's token writer used a
399
- fixed `.tmp` name and could wipe valid tokens when two refreshes raced. The
400
- Google writer uses a unique temp name per write and a refresh catch that never clears
401
- valid tokens on an IO error. A losing concurrent writer must not wipe credentials.
333
+ - **Pending state is a PKCE verifier, not a device code.** Register writes it;
334
+ `register-submit` reads and clears it. Single-slot per account, refuses a
335
+ second concurrent register with an `in-progress` signal, cleared on any
336
+ terminal outcome.
337
+ - **The concurrent-refresh race is not reproduced.** Outlook's token writer used
338
+ a fixed `.tmp` name and could wipe valid tokens when two refreshes raced. The
339
+ Google writer uses a unique temp name per write, and its refresh catch never
340
+ clears valid tokens on an IO error. A losing concurrent writer must not wipe
341
+ credentials.
402
342
 
403
- Refresh triggers automatically within 5 minutes of access-token expiry, mirroring Outlook.
404
- The 7-day Testing-status refresh-token expiry above is separate: it is Google forcing
405
- re-consent until the brand is verified, not a refresh failure, and the health signal must
406
- name it distinctly.
343
+ Refresh runs **in-process** against Google, within 5 minutes of access-token
344
+ expiry. No `platform/ui` code mints, exchanges or refreshes a credential; its
345
+ only Google surface is the standing pending audit below, which reads the age of
346
+ `pending-oauth.enc` and never decrypts it.
407
347
 
408
348
  ---
409
349
 
410
- ## Live test that closes the Phase 0 gate
411
-
412
- Run this once a real Google OAuth client exists, before building the register path. It
413
- converts the predictions above into recorded facts.
414
-
415
- **Prerequisite: add test users.** A newly created client sits in **Testing**, where consent
416
- is refused for anyone not on the consent screen's test-user list — including the operator.
417
- Add the testing Google account (and any client account used for the trial) before attempting
418
- step 2, or consent dead-ends with an access-denied screen that looks like a flow bug.
419
-
420
- **The round-trip does not require the callback route to exist.** The route is Phase 2 code,
421
- and Phase 0 must not build it. Google still redirects the browser to the callback URL with
422
- `?code=...` in the query string; the page 404s (the allowlist entry is not added yet) but
423
- the address bar carries the code. Copying it from there and exchanging it by hand proves
424
- everything the gate asks about, with no plugin code written.
425
-
426
- 1. **Authorization-code round-trip (Verdict 3, Option B) — the load-bearing step.** With the
427
- Web-application client, open the consent URL for the Calendar scope with
428
- `access_type=offline` and `prompt=consent`, consent as a listed test user, then copy the
429
- `code` value from the address bar of the 404'd callback page. Exchange it against
430
- `https://oauth2.googleapis.com/token` with the client id, client secret, the same
431
- `redirect_uri`, and `grant_type=authorization_code`. Confirm the response carries a
432
- **refresh token** and the granted `scope` includes the Calendar scope. Run this on the
433
- device, where the client secret already lives; never paste the secret into a transcript.
434
- Record the granted scope and the presence of a refresh token (never the tokens
435
- themselves).
436
- 2. **Device-code refusal (Verdict 1) — optional, confirmatory only.** Verdict 1 already
437
- rests on Google's published allowlist, which does not list any Calendar scope. Confirming
438
- it empirically needs a *second*, throwaway client of type "TV and Limited Input", since a
439
- Web-application client cannot exercise the device grant. Worth doing only if the recorded
440
- refusal is wanted for the file; it changes no decision, because step 1 succeeding already
441
- establishes the flow being built.
442
- 3. **Narrow-scope check (optional).** Repeat step 1 with `calendar.events` +
443
- `calendar.readonly` + `calendar.freebusy` instead of `calendar`, and confirm event
444
- create/update/cancel/respond/free-busy all work, to decide the narrower scope for Phase 2.
445
-
446
- Paste the recorded outcome into this section. Once step 1 is recorded, the Phase 0 gate is
447
- met and Phases 1–2 (the plugin build) proceed against the confirmed flow.
350
+ ## Standing audits
351
+
352
+ Both states these cover are unobservable by any event: an abandoned registration
353
+ fires nothing, and a refresh token expiring is a date passing, not an action.
354
+ `runAccountRegisterPoll` emits `op=pending-stranded` only when somebody polls —
355
+ and the case worth catching is the one nobody polls.
356
+
357
+ - **Stranded pending.** In-process in `platform/ui`
358
+ (`server/google-pending-audit.ts`), every 5 minutes, over every account uuid
359
+ under `data/accounts`. Any `pending-oauth.enc` at or past
360
+ `PENDING_LIFETIME_SEC` (600s) emits
361
+ `[google-oauth] op=pending-stranded account=<id> ageSec=<N>`. It runs in-process
362
+ rather than in the spawned script precisely so the signal does not depend on a
363
+ spawn being healthy the same rationale as Outlook's `devicecode-stranded`
364
+ audit. A pending file that vanishes mid-scan (a concurrent submit clearing it)
365
+ is skipped per-account; it must never abort the cycle, because that would
366
+ suppress the heartbeat and read as the audit being dead.
367
+ - **Refresh TTL.** Spawned `mcp/dist/scripts/account-audit.js`, hourly, one
368
+ `[google-account-audit] account=<id> email=<addr> refreshTtlDays=<D> status=<…>`
369
+ per connected Google account. It is a spawned script, not in-process, because
370
+ classifying a token means decrypting the blob with the per-account AES key and
371
+ `TokenStore` is the only holder of that contract. An unreadable blob is skipped,
372
+ never fatal one corrupt store must not blind the audit to every other account.
373
+
374
+ **Heartbeats, and why they are not optional.** Each audit emits a heartbeat every
375
+ cycle, including empty ones. Without it, an audit that has stopped running looks
376
+ exactly like an audit reporting all-clear, and the operator learns nothing until
377
+ an account has already fallen off. The absence of a heartbeat across more than
378
+ one interval is the failure signal. A pending cycle that throws logs
379
+ `op=pending-audit-error` a deliberately distinct token, so a broken cycle is
380
+ never miscounted as a healthy heartbeat by a reader grepping `op=pending-audit`.
381
+
382
+ **What `refreshTtlDays` is, exactly.** Days remaining on Google's 180-day
383
+ inactivity revocation clock, computed as `180 - daysSince(lastRefresh)` and
384
+ clamped so a clock that ran ahead cannot report more than 180. `TokenBlob` stores
385
+ no refresh-token expiry because Google issues none, so this inactivity clock is
386
+ the only bounded quantity available locally. It does **not** predict the other
387
+ revocation causes the user revoking access, a password change, the app being
388
+ unpublished — none of which are visible from disk. `refreshToken: null` is
389
+ reported `expired` with zero days however recently it was written, since such a
390
+ connection dies with its access token about an hour out.
391
+
392
+ Diagnostic path:
448
393
 
449
- ### Provisioned clients
394
+ ```
395
+ ~/<brand>-code/platform/scripts/logs-read.sh --tail server 200 \
396
+ | grep -E '\[google-oauth\]|\[google-account-audit\]'
397
+ ```
450
398
 
451
- | Brand | Client type | Redirect URI | Client ID | Status |
452
- |---|---|---|---|---|
453
- | `sitedesk-code` | ~~Web application~~ **RETIRED** | ~~`https://public.sitedesk.online/api/google/oauth/callback`~~ | `1032860640027-…` | **Do not use.** A Web client cannot register a loopback redirect, so this id fails `redirect_uri_mismatch` under the Desktop flow. Cleared from `brand.json`; delete the client in the console and revoke its secret. |
454
- | `sitedesk-code` | Desktop app | `http://127.0.0.1` | *(not yet created)* | Awaiting provisioning in the **existing** `Sitedesk Calendar` project, so consent-screen and verification progress carry over. |
399
+ ---
455
400
 
456
- The public host was confirmed live and routing to the `platform/ui` server (it serves the
457
- brand's public page). No secret or token is recorded here. The Web client's secret was exposed to shell history during the manual round-trip.
458
- That client is retired rather than rotated: delete it in the console, which
459
- revokes the secret with it.
401
+ ## What was removed
402
+
403
+ The earlier hosted-callback model built a hosted HTTPS callback at
404
+ `https://<brand-public-host>/api/google/oauth/callback`, served by the
405
+ `platform/ui` server, plus a refresh route and a house-only secret file. A
406
+ Desktop client cannot register an HTTPS redirect, so all of it is deleted:
407
+ `platform/ui/server/routes/google-oauth.ts` and its test, the
408
+ `/api/google/oauth/callback` allowlist entry, the mount in `server/index.ts`,
409
+ and the `MAXY_UI_INTERNAL_PORT` dependency. No OAuth surface remains in
410
+ `platform/ui` — no route, no exchange, no credential — and a test asserts the
411
+ public host still refuses that path. The one `platform/ui` file that mentions
412
+ Google is `server/google-pending-audit.ts`, which reads the age of a pending file
413
+ and is not reachable over HTTP at all.
414
+
415
+ **Devices touched by the earlier plugin may still hold
416
+ `platform/config/google-house.env`, containing the retired Web client's live
417
+ secret.** Nothing reads it and nothing removes it. Delete it on each such
418
+ device.
460
419
 
461
- **Devices touched by the earlier plugin may still hold `platform/config/google-house.env`
462
- containing that live Web-client secret.** Nothing reads it any more and nothing
463
- removes it. Delete it on each such device as part of retiring the Web client.
420
+ ### Provisioned clients
464
421
 
465
- Per-brand next step for `sitedesk-code`: start **brand/consent-screen verification** to lift
466
- the 100-test-user cap and the 7-day re-consent measured above. That review is the slow part;
467
- everything else is minutes.
422
+ | Brand | Client type | Redirect URI | Client ID | Status |
423
+ |---|---|---|---|---|
424
+ | `sitedesk-code` | ~~Web application~~ **RETIRED** | ~~`https://public.sitedesk.online/api/google/oauth/callback`~~ | `1032860640027-…` | **Do not use.** A Web client cannot register a loopback redirect, so this id fails `redirect_uri_mismatch` under the Desktop flow. Cleared from `brand.json`. Delete the client in the console, which revokes its secret with it — it was exposed to shell history during the manual round-trip, so it is retired rather than rotated. |
425
+ | `sitedesk-code` | Desktop app | `http://127.0.0.1` | *(not yet created)* | Awaiting provisioning in the **existing** `Sitedesk Calendar` project. |
426
+
427
+ No brand currently ships a populated `googleClientId`/`googleClientSecret`; the
428
+ plugin is inert everywhere until one does. Per-brand next step for
429
+ `sitedesk-code`: create the Desktop client per the runbook above, then start
430
+ **brand/consent-screen verification** to lift the 100-test-user cap and the
431
+ 7-day re-consent. That review is the slow part; everything else is minutes.