@rubytech/create-maxy-code 0.1.472 → 0.1.474

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 (142) hide show
  1. package/dist/__tests__/google-client-id-env.test.js +15 -12
  2. package/dist/index.js +6 -29
  3. package/dist/port-resolution.js +5 -5
  4. package/package.json +1 -1
  5. package/payload/platform/config/brand.json +1 -0
  6. package/payload/platform/docs/superpowers/plans/2026-07-18-ledger-write-path-fix.md +109 -0
  7. package/payload/platform/lib/ledger-core/dist/reconcile.d.ts +7 -0
  8. package/payload/platform/lib/ledger-core/dist/reconcile.d.ts.map +1 -1
  9. package/payload/platform/lib/ledger-core/dist/reconcile.js +5 -2
  10. package/payload/platform/lib/ledger-core/dist/reconcile.js.map +1 -1
  11. package/payload/platform/lib/ledger-core/src/__tests__/reconcile.test.ts +20 -0
  12. package/payload/platform/lib/ledger-core/src/reconcile.ts +12 -2
  13. package/payload/platform/neo4j/schema.cypher +4 -1
  14. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +37 -14
  15. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  16. package/payload/platform/plugins/docs/references/google-guide.md +20 -9
  17. package/payload/platform/plugins/docs/references/ledger.md +9 -1
  18. package/payload/platform/plugins/docs/references/troubleshooting.md +4 -0
  19. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +2 -2
  20. package/payload/platform/plugins/google/.claude-plugin/plugin.json +1 -1
  21. package/payload/platform/plugins/google/PLUGIN.md +28 -14
  22. package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js +79 -28
  23. package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js.map +1 -1
  24. package/payload/platform/plugins/google/mcp/dist/__tests__/boot.test.d.ts +2 -0
  25. package/payload/platform/plugins/google/mcp/dist/__tests__/boot.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/google/mcp/dist/__tests__/boot.test.js +54 -0
  27. package/payload/platform/plugins/google/mcp/dist/__tests__/boot.test.js.map +1 -0
  28. package/payload/platform/plugins/google/mcp/dist/__tests__/google-client.test.js +26 -1
  29. package/payload/platform/plugins/google/mcp/dist/__tests__/google-client.test.js.map +1 -1
  30. package/payload/platform/plugins/google/mcp/dist/__tests__/index-registration.test.js +3 -2
  31. package/payload/platform/plugins/google/mcp/dist/__tests__/index-registration.test.js.map +1 -1
  32. package/payload/platform/plugins/google/mcp/dist/index.d.ts +9 -5
  33. package/payload/platform/plugins/google/mcp/dist/index.d.ts.map +1 -1
  34. package/payload/platform/plugins/google/mcp/dist/index.js +38 -37
  35. package/payload/platform/plugins/google/mcp/dist/index.js.map +1 -1
  36. package/payload/platform/plugins/google/mcp/dist/lib/google-client.d.ts +10 -7
  37. package/payload/platform/plugins/google/mcp/dist/lib/google-client.d.ts.map +1 -1
  38. package/payload/platform/plugins/google/mcp/dist/lib/google-client.js +25 -13
  39. package/payload/platform/plugins/google/mcp/dist/lib/google-client.js.map +1 -1
  40. package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts +58 -17
  41. package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts.map +1 -1
  42. package/payload/platform/plugins/google/mcp/dist/tools/account-register.js +143 -37
  43. package/payload/platform/plugins/google/mcp/dist/tools/account-register.js.map +1 -1
  44. package/payload/platform/plugins/google/references/auth.md +264 -362
  45. package/payload/platform/plugins/ledger/PLUGIN.md +13 -1
  46. package/payload/platform/plugins/ledger/mcp/dist/index.js +23 -15
  47. package/payload/platform/plugins/ledger/mcp/dist/index.js.map +1 -1
  48. package/payload/platform/plugins/ledger/mcp/dist/lib/read.d.ts +11 -1
  49. package/payload/platform/plugins/ledger/mcp/dist/lib/read.d.ts.map +1 -1
  50. package/payload/platform/plugins/ledger/mcp/dist/lib/read.js +20 -1
  51. package/payload/platform/plugins/ledger/mcp/dist/lib/read.js.map +1 -1
  52. package/payload/platform/plugins/ledger/mcp/dist/lib/types.d.ts +14 -11
  53. package/payload/platform/plugins/ledger/mcp/dist/lib/types.d.ts.map +1 -1
  54. package/payload/platform/plugins/ledger/mcp/dist/lib/types.js.map +1 -1
  55. package/payload/platform/plugins/ledger/mcp/dist/lib/write-rejects.d.ts +12 -0
  56. package/payload/platform/plugins/ledger/mcp/dist/lib/write-rejects.d.ts.map +1 -0
  57. package/payload/platform/plugins/ledger/mcp/dist/lib/write-rejects.js +50 -0
  58. package/payload/platform/plugins/ledger/mcp/dist/lib/write-rejects.js.map +1 -0
  59. package/payload/platform/plugins/ledger/mcp/dist/tools/cash-record.d.ts +5 -5
  60. package/payload/platform/plugins/ledger/mcp/dist/tools/cash-record.d.ts.map +1 -1
  61. package/payload/platform/plugins/ledger/mcp/dist/tools/cash-record.js +11 -11
  62. package/payload/platform/plugins/ledger/mcp/dist/tools/cash-record.js.map +1 -1
  63. package/payload/platform/plugins/ledger/mcp/dist/tools/invoice-record.d.ts +7 -2
  64. package/payload/platform/plugins/ledger/mcp/dist/tools/invoice-record.d.ts.map +1 -1
  65. package/payload/platform/plugins/ledger/mcp/dist/tools/invoice-record.js +9 -3
  66. package/payload/platform/plugins/ledger/mcp/dist/tools/invoice-record.js.map +1 -1
  67. package/payload/platform/plugins/ledger/mcp/dist/tools/payment-record.d.ts +7 -0
  68. package/payload/platform/plugins/ledger/mcp/dist/tools/payment-record.d.ts.map +1 -1
  69. package/payload/platform/plugins/ledger/mcp/dist/tools/payment-record.js +3 -4
  70. package/payload/platform/plugins/ledger/mcp/dist/tools/payment-record.js.map +1 -1
  71. package/payload/platform/plugins/ledger/mcp/dist/tools/reconcile.d.ts +1 -1
  72. package/payload/platform/plugins/ledger/mcp/dist/tools/reconcile.d.ts.map +1 -1
  73. package/payload/platform/plugins/ledger/mcp/dist/tools/reconcile.js +4 -1
  74. package/payload/platform/plugins/ledger/mcp/dist/tools/reconcile.js.map +1 -1
  75. package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
  76. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +3 -3
  77. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  78. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.d.ts +14 -1
  79. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  80. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.js +15 -0
  81. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.js.map +1 -1
  82. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  83. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +3 -1
  84. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  85. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +51 -4
  86. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +4 -0
  87. package/payload/platform/scripts/backfill-task-ids.sh +206 -0
  88. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +27 -2
  89. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +33 -2
  91. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  93. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  94. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  95. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  96. package/payload/platform/services/claude-session-manager/dist/http-server.js +3 -0
  97. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  98. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  99. package/payload/server/{chunk-BSTIQX52.js → chunk-Y6V45R6M.js} +1 -12
  100. package/payload/server/maxy-edge.js +1 -1
  101. package/payload/server/public/activity.html +6 -6
  102. package/payload/server/public/assets/{AdminLoginScreens-BfamGqS7.js → AdminLoginScreens-B44KbEuC.js} +1 -1
  103. package/payload/server/public/assets/{AdminShell-5nHOAzXO.js → AdminShell-BjxV2qkb.js} +2 -2
  104. package/payload/server/public/assets/{Checkbox-CD6aHwac.js → Checkbox-DSeZNGNA.js} +1 -1
  105. package/payload/server/public/assets/activity-CdipMTDp.js +1 -0
  106. package/payload/server/public/assets/{admin-Cpcw_YE-.js → admin-B4LpTCur.js} +1 -1
  107. package/payload/server/public/assets/{browser-0znOwk1W.js → browser-CEFvaWwI.js} +1 -1
  108. package/payload/server/public/assets/{calendar-CykigHdg.js → calendar-B3zo6V34.js} +1 -1
  109. package/payload/server/public/assets/chat-5tJdo9i_.js +1 -0
  110. package/payload/server/public/assets/chevron-left-BJllrPBj.js +1 -0
  111. package/payload/server/public/assets/data-Du79EgR-.js +1 -0
  112. package/payload/server/public/assets/{graph-JV5_xF3V.js → graph-RaOrnSf2.js} +1 -1
  113. package/payload/server/public/assets/{graph-labels-DPYORjBH.js → graph-labels-GlTEIYJj.js} +1 -1
  114. package/payload/server/public/assets/{maximize-2-8vKr3h21.js → maximize-2-CHDa8p5h.js} +1 -1
  115. package/payload/server/public/assets/{operator-Liul1QEq.js → operator-DtqHY4jV.js} +1 -1
  116. package/payload/server/public/assets/{page-CYRA87CB.js → page-CkhFj8GQ.js} +1 -1
  117. package/payload/server/public/assets/{page-Cg7B1RQx.js → page-DxnWgPtd.js} +1 -1
  118. package/payload/server/public/assets/{public-B4HMtpjt.js → public-D5TUfQ3I.js} +1 -1
  119. package/payload/server/public/assets/{rotate-ccw-sz5IQSnr.js → rotate-ccw-BJjVAQQY.js} +1 -1
  120. package/payload/server/public/assets/tasks-CM9pYYG1.js +1 -0
  121. package/payload/server/public/assets/{time-entry-format-CSuCexpM.js → time-entry-format-C5RYEYjM.js} +1 -1
  122. package/payload/server/public/assets/{triangle-alert-ClS81-CU.js → triangle-alert-DLfIsuo_.js} +1 -1
  123. package/payload/server/public/assets/{useCopyFeedback-Cn6YgeNx.js → useCopyFeedback-7yKeiAzV.js} +1 -1
  124. package/payload/server/public/assets/{useSelectionMode-BaD-ZZRk.js → useSelectionMode-CWlh6AEq.js} +1 -1
  125. package/payload/server/public/assets/{useSubAccountSwitcher-CNZL3PKn.css → useSubAccountSwitcher-B2n9QPVI.css} +1 -1
  126. package/payload/server/public/assets/{useVoiceRecorder-BOzHrDAU.js → useVoiceRecorder-j3ZArZ2H.js} +1 -1
  127. package/payload/server/public/browser.html +5 -5
  128. package/payload/server/public/calendar.html +6 -6
  129. package/payload/server/public/chat.html +12 -12
  130. package/payload/server/public/data.html +11 -11
  131. package/payload/server/public/graph.html +11 -11
  132. package/payload/server/public/index.html +14 -14
  133. package/payload/server/public/operator.html +14 -14
  134. package/payload/server/public/public.html +12 -12
  135. package/payload/server/public/tasks.html +5 -5
  136. package/payload/server/server.js +1837 -1695
  137. package/payload/server/public/assets/activity-Dj2nJzPw.js +0 -1
  138. package/payload/server/public/assets/chat-DIOJZ411.js +0 -1
  139. package/payload/server/public/assets/chevron-left-Cq7UvOI0.js +0 -1
  140. package/payload/server/public/assets/data-lHgqlhmu.js +0 -1
  141. package/payload/server/public/assets/tasks-D9oYgEzs.js +0 -1
  142. /package/payload/server/public/assets/{useSubAccountSwitcher-CvjWJSGv.js → useSubAccountSwitcher-CBlKgwms.js} +0 -0
@@ -1,450 +1,352 @@
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
- Phases 1–2 may now proceed against the flow below. Client-secret storage is decided:
20
- house-only at `${PLATFORM_ROOT}/config/google-house.env` (see below).
21
-
22
- ## Original spike verdict (documentation-backed, now confirmed)
23
-
24
- This is the Phase 0 deliverable: the OAuth-flow verdict for a per-account
25
- **Google Calendar** plugin on a headless install where the operator consents from a phone
26
- over WhatsApp, with no browser on the box.
27
-
28
- Scope note: this plugin is **calendar-only**. Mail stays on the `email` plugin's IMAP/SMTP
29
- path; there is no Gmail API surface. That choice is deliberate and it is what keeps this
30
- plugin out of Google's restricted-scope security review (see
31
- ["Verification"](#verification-the-light-kind-not-the-security-assessment)). Contacts
32
- (People API, read-only) is a possible later addition on the same footing but is not in this
33
- plugin.
34
-
35
- Every claim below is grounded in Google's **published** behaviour, cited inline. What is
36
- **not** yet done is the live token test the task requires: a real token issued (or refused)
37
- by an actual Google OAuth client for the Calendar scope. Until that runs
38
- (["Live test that closes the Phase 0 gate"](#live-test-that-closes-the-phase-0-gate)),
39
- this document is a prediction, not a proof. Do **not** build the register/register-poll
40
- path against it before the live test confirms the verdict.
41
-
42
- What is decided from published behaviour:
43
-
44
- - The **device-authorization grant is rejected** for this plugin. Google's device flow
45
- serves only a fixed allowlist of scopes; Calendar is not on it.
46
- - The flow is therefore an **operator-assisted authorization-code (PKCE) flow**, and it
47
- needs a redirect method, because Google's out-of-band ("paste the code") flow is fully
48
- blocked.
49
- - The recommended redirect method is a **hosted HTTPS callback on the brand's existing
50
- Cloudflare tunnel domain** (a "Web application" client), with a **loopback + manual
51
- code relay** (a "Desktop app" client) as the secret-less fallback.
52
- - **Verification is the light kind.** Calendar is a *sensitive* scope, not a *restricted*
53
- one, so a brand's app needs only OAuth brand/consent-screen verification, never the
54
- annual third-party security assessment.
55
-
56
- This diverges from Outlook on one axis: Outlook's device-code flow is secret-less, and the
57
- recommended Google flow holds a per-brand client secret. That divergence is forced by
58
- 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.
59
9
 
60
10
  ---
61
11
 
62
- ## Verdict 1: device-code is rejected (scope allowlist)
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`:
63
32
 
64
- Outlook uses Microsoft's `/devicecode` grant so the operator enters a code on their own
65
- phone and never needs a browser on the box. The first question for Google was whether the
66
- same grant works.
33
+ ```jsonc
34
+ // brands/<brand>/brand.json
35
+ "googleClientId": "<the client id>",
36
+ "googleClientSecret": "<the client secret>"
37
+ ```
67
38
 
68
- It does not, for the Calendar scope. Google's OAuth 2.0 flow for TV and limited-input
69
- devices is supported only for a fixed allowlist. Verbatim from Google's documentation
70
- ([OAuth 2.0 for TV and Limited-Input Device Applications](https://developers.google.com/identity/protocols/oauth2/limited-input-device)):
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.
71
42
 
72
- > The OAuth 2.0 flow for devices is supported only for the following scopes:
73
- >
74
- > **OpenID Connect, Google Sign-In:** `email`, `openid`, `profile`
75
- > **Drive API:** `https://www.googleapis.com/auth/drive.appdata`, `https://www.googleapis.com/auth/drive.file`
76
- > **YouTube API:** `https://www.googleapis.com/auth/youtube`, `https://www.googleapis.com/auth/youtube.readonly`
43
+ ### Pre-flight: confirm the client accepts loopback
44
+
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.
77
51
 
78
- The Calendar scope (`https://www.googleapis.com/auth/calendar`) is absent. A device-flow
79
- authorization request that includes it is refused. The device-code grant cannot represent
80
- this plugin's calendar.
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.
81
56
 
82
- **The live test still owes the verbatim refusal.** The task's gate is a real refusal from a
83
- real client, not this allowlist. The live test below issues a `/device/code` request for
84
- the Calendar scope against a real client of type "TV and Limited Input" and records the
85
- exact error body. Expected: a `403`/`invalid_scope`-class refusal naming the scope. The
86
- allowlist above is the reason to expect it; the recorded response is the proof.
57
+ Sends the client id only, never the secret. Non-mutating.
87
58
 
88
- ## Verdict 2: OOB ("paste a code") is dead — a redirect is mandatory
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
+ ```
89
65
 
90
- The other headless-friendly pattern is out-of-band: Google shows the operator a code on a
91
- web page, the operator pastes it back, no redirect URI. That flow is gone. Google blocked
92
- new OOB use on 28 Feb 2022 and blocked all existing clients on 31 Jan 2023
93
- ([OOB migration guide](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration);
94
- [Google Developers Blog](https://developers.googleblog.com/making-google-oauth-interactions-safer-by-using-more-secure-oauth-flows/)).
95
- A `redirect_uri=urn:ietf:wg:oauth:2.0:oob` request is refused.
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.
96
69
 
97
- So consent must land on a real `redirect_uri`. For an installed/desktop app Google's named
98
- replacement is the **loopback IP redirect** (`http://127.0.0.1:<port>` or
99
- `http://localhost:<port>`), and it "requires you to be listening on a local web server
100
- ... to receive the authorization code" (OOB migration guide). Google's migration guidance
101
- covers mobile, Chrome, web, and desktop clients; it gives **no** cross-device pattern for
102
- a headless box where consent happens on a separate phone. That gap is the design problem
103
- this plugin has to solve itself.
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:
104
73
 
105
- ## Verdict 3: the flow — operator-assisted authorization-code (PKCE)
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
77
+ ```
106
78
 
107
- Because the operator's browser is on a phone and the plugin is on the box, the redirect
108
- that carries the `code` and the process that exchanges it live on different machines. Two
109
- concrete flows bridge that. The recommendation is Option B primary, Option A as the
110
- secret-less fallback.
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.
111
84
 
112
- ### Option B (recommended): hosted HTTPS callback on the brand tunnel domain
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.
113
88
 
114
- Client type **Web application**. A stable authorized redirect URI on the install's existing
115
- Cloudflare **public** host, served by the always-running `platform/ui` server (the same
116
- process that runs the Outlook auto-poll dispatcher):
89
+ Run the probe **twice**, and read the pair:
117
90
 
118
- ```
119
- https://<brand-public-host>/api/google/oauth/callback
120
- ```
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 |
121
95
 
122
- **The `/api/` prefix is mandatory, not cosmetic.** The public host is not a general web
123
- server: it admits only an allowlist of path prefixes and 404s everything else
124
- (`platform/ui/server/public-host-gate.ts`, `PUBLIC_ALLOWED_PREFIXES`). A bare
125
- `/google/oauth/callback` would 404 before reaching any route. Phase 2 must add
126
- `/api/google/` to that allowlist in the same commit as the callback route.
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.
127
99
 
128
- The public host is also the correct *class* of host, and there is an exact in-repo
129
- precedent. QuickBooks already solved this identical problem, and the allowlist records why:
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.
130
105
 
131
- > QuickBooks OAuth consent callback. Intuit redirects the operator's browser here with no
132
- > admin cookie; the route authenticates by validating the OAuth `state` nonce, not the
133
- > remote-auth gate (same model as `/api/telegram/`).
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.
134
108
 
135
- Google's redirect has the same shape: the client's phone browser arrives with no admin
136
- cookie, so the route authenticates on the `state` nonce rather than the remote-auth gate.
137
- Mirror `/api/quickbooks/` rather than inventing a new pattern.
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:
138
112
 
139
- ```
140
- operator (phone) plugin / platform-ui (box) Google
141
- | | |
142
- | google-account-register | |
143
- |--------------------------------------->| build PKCE (verifier+challenge), |
144
- | | state; write pending-oauth.enc |
145
- | | build consent URL (access_type= |
146
- | | offline, prompt=consent, calendar |
147
- | | scope, redirect=<tunnel>/cb) |
148
- | { status:"pending", consentUrl } | |
149
- |<---------------------------------------| |
150
- | open consentUrl on phone, sign in, | |
151
- | consent -------------------------------------------------------------------> |
152
- | | GET /api/google/oauth/callback?code&state <-- redirect
153
- | | verify state, exchange code+verifier |
154
- | | (+client secret) ---------------> |
155
- | | < tokens (access+refresh) |
156
- | | GET userinfo → account email/id |
157
- | | encrypt → accounts/<id>/tokens.enc |
158
- | | clear pending |
159
- | google-account-register-poll → { registered, email } |
160
- |<---------------------------------------| |
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")'
161
115
  ```
162
116
 
163
- The operator sees a clean "connected, return to WhatsApp" page; the code is captured
164
- server-side, never relayed by hand. `register-poll` reports success from local state,
165
- exactly like Outlook's poll. This is the better ergonomic match for a non-technical
166
- operator on WhatsApp, which is the whole reason device-code was wanted.
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.
167
123
 
168
- Cost: a Web-application client carries a **real client secret** and a per-brand authorized
169
- redirect URI. The secret is confidential and lives in the brand/account secret store, never
170
- bundled into the installer. This is the one divergence from Outlook.
124
+ Confirm the stamp took from the boot line, which reports presence and never
125
+ values:
171
126
 
172
- ### Option A (fallback): loopback client + manual code relay
173
-
174
- Client type **Desktop app**, redirect `http://127.0.0.1:<port>`. No hosted endpoint, no
175
- per-brand redirect URI beyond the fixed loopback, and the installed-app secret Google
176
- issues for desktop clients is documented as non-confidential.
127
+ ```bash
128
+ grep -m1 'google-mcp. boot' ~/.<brand>-code/logs/server.log
129
+ # ... clientIdConfigured=true clientSecretConfigured=true
130
+ ```
177
131
 
178
- The operator opens the consent URL on the phone and consents; Google redirects the phone
179
- browser to `http://localhost:<port>/?code=...`, which fails to load (nothing listens on the
180
- phone). The operator copies the `code` value out of the address bar of the failed page and
181
- sends it back via a `google-account-register-submit` tool; the plugin exchanges it with the
182
- stored PKCE verifier.
132
+ Either reading `false` on a brand that ships both is the mis-stamp signature.
183
133
 
184
- Cost: extracting a `code` from a failed-load address bar on a phone is fragile — some mobile
185
- browsers hide the URL of a failed navigation. Use this only where a brand has no usable
186
- hosted callback. The live test measures whether this UX is acceptable before it is relied on.
134
+ ### Connecting an account, once the brand ships a client
187
135
 
188
- ### Why the recommendation, and what the live test decides
136
+ `google-account-register` returns a consent URL. The user opens it on any
137
+ device and approves. Google redirects to `http://127.0.0.1:8571/?code=…`, which
138
+ **fails to load by design** — nothing listens there, and the consenting browser
139
+ is the user's own phone. The user copies the `code` value out of the address bar
140
+ of that failed page and passes it to `google-account-register-submit`, which
141
+ exchanges it in-process. `google-account-register-poll` reports the outcome at
142
+ any point.
189
143
 
190
- Option B wins on operator UX and robustness at the cost of a per-brand secret. Option A
191
- keeps provisioning minimal and secret-light at the cost of a fragile mobile step. The live
192
- test resolves the open question that Option B's hosted callback round-trips a real token
193
- for the Calendar scope — before any register code is built. If the tunnel-hosted callback
194
- is not viable for a given brand, Option A is the recorded fallback.
144
+ That failed-page step is a real UX cost, priced and accepted: some mobile
145
+ browsers hide the URL of a failed navigation. It is the price of a client type
146
+ whose secret can legitimately ship to customer devices.
195
147
 
196
148
  ---
197
149
 
198
- ## Client-id / secret model per brand
150
+ ## Why this shape
199
151
 
200
- One vendor-owned Google Cloud OAuth client per brand serves every install and every account
201
- on that brand, exactly as `outlookClientId` does. The client id ships as the brand default
202
- `brands/<brand>/brand.json#googleClientId`, stamped onto the session-manager unit env as
203
- `GOOGLE_CLIENT_ID` and inherited down the spawn chain (manager unit env → PTY → MCP child).
152
+ ### Device-code is rejected (scope allowlist)
204
153
 
205
- Divergence from Outlook: the recommended Web-application flow (Option B) also needs a
206
- **client secret**, which lives house-only at `${PLATFORM_ROOT}/config/google-house.env`
207
- (decided; see below). A brand that ships `googleClientId` empty has the plugin inert with an
208
- actionable signal (`clientIdConfigured=false`), register refuses, and no operator action
209
- makes it work until the vendor client exists — mirroring Outlook's inert behaviour.
154
+ Outlook uses Microsoft's `/devicecode` grant, so the operator never needs a
155
+ browser on the box. That grant does not work for Calendar. Google's flow for TV
156
+ and limited-input devices serves only a fixed allowlist. Verbatim from
157
+ [OAuth 2.0 for TV and Limited-Input Device Applications](https://developers.google.com/identity/protocols/oauth2/limited-input-device):
210
158
 
211
- ### Installing the secret on a device (operator step, once per install)
159
+ > The OAuth 2.0 flow for devices is supported only for the following scopes:
160
+ >
161
+ > **OpenID Connect, Google Sign-In:** `email`, `openid`, `profile`
162
+ > **Drive API:** `https://www.googleapis.com/auth/drive.appdata`, `https://www.googleapis.com/auth/drive.file`
163
+ > **YouTube API:** `https://www.googleapis.com/auth/youtube`, `https://www.googleapis.com/auth/youtube.readonly`
212
164
 
213
- Nothing automates this and nothing can: the secret cannot ship in the npm
214
- package, so it is placed by hand after the install exists. Until it is, the
215
- plugin's tools load and `google-account-register` refuses with a message naming
216
- this file — the refusal is deliberate and happens *before* the user consents,
217
- so nobody burns a consent on a flow that cannot complete.
165
+ The Calendar scope is absent, so a device-flow request carrying it is refused.
166
+ The flow is therefore authorization-code + PKCE.
218
167
 
219
- On the device, with the values from the brand's Google Cloud OAuth client:
168
+ ### Out-of-band is dead, so a redirect is mandatory
220
169
 
221
- ```bash
222
- INSTALL=~/<brand>-code # e.g. ~/sitedesk-code
223
- ( umask 077; mkdir -p "$INSTALL/platform/config" )
224
- ( umask 077; cat > "$INSTALL/platform/config/google-house.env" <<'EOF'
225
- GOOGLE_CLIENT_ID=<the client id>
226
- GOOGLE_CLIENT_SECRET=<the client secret>
227
- EOF
228
- )
229
- chmod 600 "$INSTALL/platform/config/google-house.env"
230
- ```
170
+ Google blocked new out-of-band use on 28 Feb 2022 and all existing clients on
171
+ 31 Jan 2023 ([OOB migration guide](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration)).
172
+ A `redirect_uri=urn:ietf:wg:oauth:2.0:oob` request is refused. Consent must land
173
+ on a real redirect URI.
231
174
 
232
- Then restart the install's UI service so the route picks it up.
175
+ ### The secret cannot be avoided, so the client type must sanction shipping it
233
176
 
234
- Confirm it took without printing the secret the boot line reports presence
235
- only:
177
+ Google requires `client_secret` at the token endpoint for **every** client type,
178
+ including Web clients using PKCE. There is no public-client path, so any install
179
+ performing its own exchange holds one.
236
180
 
237
- ```bash
238
- grep -m1 'google-mcp. boot' ~/.<brand>-code/logs/server.log
239
- # ... houseSecretPresent=true
240
- ```
181
+ A Desktop client is the one type where Google explicitly sanctions embedding
182
+ that secret in distributed software — "the client secret is obviously not
183
+ treated as a secret". `brand.json` is therefore a legitimate carrier, and the
184
+ installer stamps `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` **together or
185
+ not at all**. A client id without its secret builds a consent URL the user can
186
+ complete but an exchange that cannot, which fails later and less legibly than
187
+ not offering the flow.
188
+
189
+ The secret ships to a **public** npm registry. That is the accepted trade: the
190
+ residual risk is app impersonation and quota abuse against the brand's client
191
+ id, **not** token theft, because PKCE still binds each code to its verifier.
241
192
 
242
- `houseSecretPresent=false` on an install whose brand ships a `googleClientId`
243
- is the mis-provision signature. The secret is never echoed, never logged, and
244
- never read by the MCP process — only its presence is checked.
193
+ Rejected, each once and for a reason:
245
194
 
246
- ### Client-secret storage: house-only (decided 2026-07-18)
195
+ - **Customer-owned apps** (the QuickBooks pattern at `quickbooks/PLUGIN.md:71`,
196
+ where the operator registers their own app). Unlike Intuit, Google demands app
197
+ verification for the sensitive Calendar scope — homepage, privacy policy,
198
+ domain ownership, demo video — which the target customer cannot produce.
199
+ - **A vendor-hosted exchange broker.** Best security and best UX, but it means
200
+ operating a credential-bearing service for every customer.
201
+ - **A Web client with the secret shipped anyway.** A Web-app secret is genuinely
202
+ confidential in Google's model; publishing it would be a real credential leak
203
+ rather than a sanctioned embed.
204
+ - **A house-only secret file** (`platform/config/google-house.env`, the earlier
205
+ hosted-callback model). Requires a manual placement per install, so it cannot reach a
206
+ customer device and contradicts installer-only doctrine.
247
207
 
248
- An earlier draft of this file said the secret is "held in the brand's secret store, the same
249
- way Cloudflare tokens are held". That was wrong — no brand-level secret store exists. The
250
- decision, mirroring the Cloudflare master token exactly:
208
+ ### The redirect is loopback, and the IP literal is mandatory
209
+
210
+ Desktop clients permit only loopback redirects. `https` is rejected outright and
211
+ custom URI schemes are withdrawn ("no longer supported due to the risk of app
212
+ impersonation"), so this is the entire option space:
251
213
 
252
214
  ```
253
- ${PLATFORM_ROOT}/config/google-house.env # mode 600, parent 700
254
- GOOGLE_CLIENT_ID=…
255
- GOOGLE_CLIENT_SECRET=…
215
+ http://127.0.0.1:8571
256
216
  ```
257
217
 
258
- Written once per install by the operator, never echoed, never in a project tree.
259
- `platform/plugins/cloudflare/references/api.md` already establishes this pattern for
260
- multi-client installs and names SiteDesk as the case: an account-wide credential inside a
261
- sub-account's secrets file lets that sub-account reach every other client's resources, so
262
- the master lives house-only at `${PLATFORM_ROOT}/config/cloudflare-house.env`, "a location
263
- no sub-account spawn sources". `google-house.env` is that file's twin.
264
-
265
- Rejected, with reasons:
266
-
267
- - **`brands/<brand>/brand.json`** ships inside the published npm package. It carries
268
- `googleClientId` (public, exactly like `outlookClientId`, and the value the inert check
269
- reads) and never the secret.
270
- - **`~/<brand>-code/data/accounts/<accountId>/secrets`** is wrong-scoped twice: the secret is
271
- per-brand not per-account, so replicating it multiplies exposure for no benefit, and on a
272
- multi-tenant install it drops a brand-wide credential into a client sub-account's own
273
- directory.
274
- - **A Desktop-type client** (installed-app secret, documented non-confidential) would remove
275
- the storage question but forces the loopback redirect and its copy-the-code-off-a-404 flow
276
- on a phone, and discards the already-provisioned, verified Web-application client.
277
-
278
- House-only is sufficient here rather than merely tidier, because the token exchange runs in
279
- the `/api/google/oauth/callback` route inside the always-running `platform/ui` server, which
280
- is house-scoped. The secret never needs to reach a per-account MCP spawn.
281
-
282
- **It is a boundary of intent, not of permission.** Every account's session runs as the same
283
- unix user and the house path is deterministic, so an agent holding `Bash` can read the file
284
- regardless; OS user separation was costed and declined. Never cite house-only placement as
285
- proof a sub-account cannot reach the secret.
218
+ **Never `localhost`.** RFC 8252 §7.3 makes the port irrelevant when matching a
219
+ loopback redirect, but only for the IP literals `127.0.0.1` and `::1`; the
220
+ hostname is matched literally. Register `http://127.0.0.1` on the client.
286
221
 
287
- ---
222
+ The port is fixed and arbitrary. Google ignores it for loopback and nothing
223
+ listens on it, so a constant keeps consent URLs deterministic and one register's
224
+ logs comparable with another's.
288
225
 
289
- ## Vendor provisioning runbook (one-time per brand)
290
-
291
- Run once per brand to mint the client whose id goes in that brand's `brand.json` and whose
292
- secret goes in the brand secret store. Not an end-user step. Each brand is a separate
293
- follow-up once the plugin lands (mirroring Outlook 1479/1485).
294
-
295
- 1. Sign in to the [Google Cloud console](https://console.cloud.google.com) with the brand's
296
- Google account. Create (or select) one project named for the brand and its Google
297
- integration, e.g. `SiteDesk Google`.
298
- 2. Enable the **Google Calendar API** (APIs & Services → Library). That is the only API this
299
- plugin calls.
300
- 3. Configure the **OAuth consent screen**: user type **External**, app name and support
301
- email set to the brand's, and add the single scope below. Publishing status starts at
302
- **Testing** and moves to **In production** once brand verification is done (see below).
303
- 4. Create the **OAuth client**:
304
- - **Option B (recommended):** application type **Web application**. Add the authorized
305
- redirect URI `https://<brand-public-host>/api/google/oauth/callback` (the `/api/`
306
- prefix is required — see Option B above). Copy the **Client ID**
307
- (→ `brand.json#googleClientId`).
308
-
309
- **The client secret is shown exactly once and cannot be retrieved afterwards.** The
310
- console displays only the last four characters from then on ("Viewing and downloading
311
- client secrets is no longer available. If you have lost the secret below, please add a
312
- new one."). Capture it at creation into its final destination, or plan to rotate. It is
313
- never pasted into a chat transcript, a shell command that lands in history, or the repo.
314
- Rotation is free and has no downtime: **Add secret**, store the new one, delete the old.
315
- It lives house-only at `${PLATFORM_ROOT}/config/google-house.env` — see above.
316
- - **Option A (fallback):** application type **Desktop app**. Copy the **Client ID**.
317
- 5. Set `googleClientId` in the brand's `brand.json` and re-bundle:
318
- ```jsonc
319
- // brands/<brand>/brand.json
320
- "googleClientId": "<the Client ID copied above>"
321
- ```
226
+ ---
322
227
 
323
- ### Scope
228
+ ## Scope
324
229
 
325
- One scope, chosen to match the Outlook calendar tool surface (read plus create, update,
326
- cancel, respond, free-busy), no broader:
230
+ One scope, chosen to match the Outlook calendar tool surface (read plus create,
231
+ update, cancel, respond, free-busy), no broader:
327
232
 
328
233
  | Purpose | Scope | Class |
329
234
  |---|---|---|
330
- | Identity (email address, sign-in) | `openid`, `email`, `profile` | non-sensitive |
331
235
  | Calendar read + event create/update/cancel/respond/free-busy | `https://www.googleapis.com/auth/calendar` | sensitive |
332
236
 
333
- Notes:
237
+ The account's email is read from `GET /calendars/primary` (whose id is the
238
+ address) rather than by requesting `openid`/`email`/`profile`, so consent stays
239
+ minimal.
240
+
241
+ A refresh token requires `access_type=offline` plus `prompt=consent` on the
242
+ consent URL; those are request parameters, not scopes.
334
243
 
335
- - `calendar` (full) is the simplest choice for read plus all the control operations. The
336
- narrower alternative is `calendar.events` + `calendar.readonly` + `calendar.freebusy`; the
337
- live test can confirm the narrower set is sufficient before Phase 2 fixes it. All Calendar
338
- scopes are *sensitive*, not *restricted*, so the narrower choice does not change the
339
- verification path.
340
- - A refresh token requires `access_type=offline` plus `prompt=consent` on the consent URL;
341
- this is a request parameter, not a scope.
244
+ The narrower alternative is `calendar.events` + `calendar.readonly` +
245
+ `calendar.freebusy`. It has never been exercised. All Calendar scopes are
246
+ *sensitive*, not *restricted*, so narrowing would not change the verification
247
+ path.
342
248
 
343
- ### Verification: the light kind, not the security assessment
249
+ ## Verification: the light kind, not the security assessment
344
250
 
345
- The Calendar scope is **sensitive**, not restricted. Google requires a production app that
346
- uses sensitive scopes to pass OAuth **brand/consent-screen verification** (own the domain,
347
- Google reviews the app name, logo, and scope justification). It does **not** require the
348
- annual third-party security assessment that applies only to *restricted* scopes such as
349
- the Gmail read/modify family, which this plugin does not use. Dropping the Gmail surface is
350
- exactly what keeps a brand off the expensive review.
251
+ Calendar is a **sensitive** scope, not a restricted one. A production app using
252
+ it needs only OAuth **brand/consent-screen verification** (own the domain;
253
+ Google reviews app name, logo, and scope justification). It does **not** need
254
+ the annual third-party security assessment, which applies only to *restricted*
255
+ scopes such as the Gmail read/modify family. Dropping the Gmail surface is
256
+ exactly what keeps a brand off that review — mail runs on the `email` plugin's
257
+ IMAP/SMTP path instead.
351
258
 
352
259
  The two publishing states, and what each costs:
353
260
 
354
- - **Testing** (no verification): works immediately for up to **100 named test accounts**,
355
- each of which sees the unverified-app warning and re-consents about **every 7 days**
356
- (Google caps refresh-token lifetime while an app is in Testing). Fine for a pilot,
357
- including the first `sitedesk-code` account.
358
- - **In production + brand-verified** (the light verification, no cost, no security firm,
359
- typically days to a couple of weeks): the 100-account cap lifts, the warning is gone, and
360
- refresh tokens are long-lived. This is the real deployment state and the target for each
361
- brand. Start it early per brand, because Google's review is the slow part.
261
+ - **Testing** (no verification): works immediately for up to **100 named test
262
+ accounts**, each seeing the unverified-app warning and re-consenting about
263
+ every 7 days. Fine for a pilot.
264
+ - **In production + brand-verified** (no cost, no security firm, typically days
265
+ to a couple of weeks): the 100-account cap lifts, the warning is gone, and
266
+ refresh tokens are long-lived. This is the real deployment state and the
267
+ target for each brand. Start it early, because Google's review is the slow
268
+ part.
269
+
270
+ The 7-day Testing cap is measured, not predicted — see below. It is Google
271
+ forcing re-consent until the brand is verified, **not** a refresh failure, and
272
+ the health signal names it distinctly.
273
+
274
+ ### Recorded round-trip (2026-07-18)
275
+
276
+ Run against the since-retired `sitedesk-code` Web client. The client type has
277
+ changed, but these facts are properties of the Calendar scope and the consent
278
+ screen, not of the client type, so they carry forward. Values deliberately not
279
+ recorded.
280
+
281
+ | Check | Result |
282
+ |---|---|
283
+ | Calendar scope granted via browser consent | **yes** — `scope=https://www.googleapis.com/auth/calendar` returned on the redirect and echoed in the token response |
284
+ | Refresh token issued (`access_type=offline`, `prompt=consent`) | **yes** — `refresh_token` present |
285
+ | Access-token lifetime | `expires_in: 3599` (1 hour) |
286
+ | Refresh-token lifetime in **Testing** | `refresh_token_expires_in: 604799` — **6.99 days**, confirming the 7-day Testing cap empirically |
287
+ | Device-code refusal | not run empirically; rests on Google's published allowlist above. Changes no decision |
288
+
289
+ The 604799 figure is the concrete argument for completing brand verification: in
290
+ Testing, every connected account silently re-consents weekly.
362
291
 
363
292
  ---
364
293
 
365
- ## Token store and pending-state layout (informs Phase 1/2, not built here)
294
+ ## Token store and pending-state layout
366
295
 
367
- The store mirrors Outlook's per-account encrypted layout: one AES-256-CBC `.key` per
368
- account, `tokens.enc` under `accounts/<id>/`, mode 0700 dirs and 0600 files, atomic
369
- temp+rename writes. This plugin is one Google account per registration, so there is no
370
- multi-mailbox nesting to mirror.
296
+ Mirrors Outlook's per-account encrypted layout: one AES-256-CBC `.key` per
297
+ account, `tokens.enc` under `accounts/<id>/`, mode 0700 dirs and 0600 files,
298
+ atomic temp+rename writes.
371
299
 
372
300
  ```
373
301
  data/accounts/<accountId>/secrets/google/
374
302
  ├── .key # 32 random bytes, base64 — per-account AES-256-CBC key
375
303
  ├── accounts/
376
304
  │ └── <userId>/tokens.enc # AES-256-CBC token JSON blob per registered Google account
377
- └── pending-oauth.enc # transient: PKCE verifier + state between register and callback/submit
305
+ └── pending-oauth.enc # transient: PKCE verifier between register and submit
378
306
  ```
379
307
 
380
- Two divergences from Outlook are required and must be honoured in Phase 2:
308
+ Two divergences from Outlook are load-bearing:
381
309
 
382
- - **Pending state is a PKCE verifier + `state`, not a device code.** Register writes it;
383
- the callback (Option B) or `register-submit` (Option A) reads and clears it. Single-slot
384
- per account, refuses a second concurrent register with an `in-progress` signal, cleared
385
- on any terminal outcome.
386
- - **The concurrent-refresh race must not be reproduced.** Outlook's token writer used a
387
- fixed `.tmp` name and could wipe valid tokens when two refreshes raced. The
388
- Google writer uses a unique temp name per write and a refresh catch that never clears
389
- valid tokens on an IO error. A losing concurrent writer must not wipe credentials.
310
+ - **Pending state is a PKCE verifier, not a device code.** Register writes it;
311
+ `register-submit` reads and clears it. Single-slot per account, refuses a
312
+ second concurrent register with an `in-progress` signal, cleared on any
313
+ terminal outcome.
314
+ - **The concurrent-refresh race is not reproduced.** Outlook's token writer used
315
+ a fixed `.tmp` name and could wipe valid tokens when two refreshes raced. The
316
+ Google writer uses a unique temp name per write, and its refresh catch never
317
+ clears valid tokens on an IO error. A losing concurrent writer must not wipe
318
+ credentials.
390
319
 
391
- Refresh triggers automatically within 5 minutes of access-token expiry, mirroring Outlook.
392
- The 7-day Testing-status refresh-token expiry above is separate: it is Google forcing
393
- re-consent until the brand is verified, not a refresh failure, and the health signal must
394
- name it distinctly.
320
+ Refresh runs **in-process** against Google, within 5 minutes of access-token
321
+ expiry. Nothing in `platform/ui` participates.
395
322
 
396
323
  ---
397
324
 
398
- ## Live test that closes the Phase 0 gate
399
-
400
- Run this once a real Google OAuth client exists, before building the register path. It
401
- converts the predictions above into recorded facts.
402
-
403
- **Prerequisite: add test users.** A newly created client sits in **Testing**, where consent
404
- is refused for anyone not on the consent screen's test-user list — including the operator.
405
- Add the testing Google account (and any client account used for the trial) before attempting
406
- step 2, or consent dead-ends with an access-denied screen that looks like a flow bug.
407
-
408
- **The round-trip does not require the callback route to exist.** The route is Phase 2 code,
409
- and Phase 0 must not build it. Google still redirects the browser to the callback URL with
410
- `?code=...` in the query string; the page 404s (the allowlist entry is not added yet) but
411
- the address bar carries the code. Copying it from there and exchanging it by hand proves
412
- everything the gate asks about, with no plugin code written.
413
-
414
- 1. **Authorization-code round-trip (Verdict 3, Option B) — the load-bearing step.** With the
415
- Web-application client, open the consent URL for the Calendar scope with
416
- `access_type=offline` and `prompt=consent`, consent as a listed test user, then copy the
417
- `code` value from the address bar of the 404'd callback page. Exchange it against
418
- `https://oauth2.googleapis.com/token` with the client id, client secret, the same
419
- `redirect_uri`, and `grant_type=authorization_code`. Confirm the response carries a
420
- **refresh token** and the granted `scope` includes the Calendar scope. Run this on the
421
- device, where the client secret already lives; never paste the secret into a transcript.
422
- Record the granted scope and the presence of a refresh token (never the tokens
423
- themselves).
424
- 2. **Device-code refusal (Verdict 1) — optional, confirmatory only.** Verdict 1 already
425
- rests on Google's published allowlist, which does not list any Calendar scope. Confirming
426
- it empirically needs a *second*, throwaway client of type "TV and Limited Input", since a
427
- Web-application client cannot exercise the device grant. Worth doing only if the recorded
428
- refusal is wanted for the file; it changes no decision, because step 1 succeeding already
429
- establishes the flow being built.
430
- 3. **Narrow-scope check (optional).** Repeat step 1 with `calendar.events` +
431
- `calendar.readonly` + `calendar.freebusy` instead of `calendar`, and confirm event
432
- create/update/cancel/respond/free-busy all work, to decide the narrower scope for Phase 2.
433
-
434
- Paste the recorded outcome into this section. Once step 1 is recorded, the Phase 0 gate is
435
- met and Phases 1–2 (the plugin build) proceed against the confirmed flow.
325
+ ## What was removed
326
+
327
+ The earlier hosted-callback model built a hosted HTTPS callback at
328
+ `https://<brand-public-host>/api/google/oauth/callback`, served by the
329
+ `platform/ui` server, plus a refresh route and a house-only secret file. A
330
+ Desktop client cannot register an HTTPS redirect, so all of it is deleted:
331
+ `platform/ui/server/routes/google-oauth.ts` and its test, the
332
+ `/api/google/oauth/callback` allowlist entry, the mount in `server/index.ts`,
333
+ and the `MAXY_UI_INTERNAL_PORT` dependency. There is zero Google surface in
334
+ `platform/ui`, and a test asserts the public host still refuses that path.
335
+
336
+ **Devices touched by the earlier plugin may still hold
337
+ `platform/config/google-house.env`, containing the retired Web client's live
338
+ secret.** Nothing reads it and nothing removes it. Delete it on each such
339
+ device.
436
340
 
437
341
  ### Provisioned clients
438
342
 
439
343
  | Brand | Client type | Redirect URI | Client ID | Status |
440
344
  |---|---|---|---|---|
441
- | `sitedesk-code` | Web application | `https://public.sitedesk.online/api/google/oauth/callback` | `1032860640027-p61l59fnog4mad5o7grginpaps9jnu8c.apps.googleusercontent.com` | **Testing** round-trip verified 2026-07-18; brand verification not started; secret pending rotation into its Phase 1 home |
442
-
443
- The public host was confirmed live and routing to the `platform/ui` server (it serves the
444
- brand's public page). No secret or token is recorded here. The secret minted at creation was
445
- exposed to shell history during the manual round-trip and should be rotated (**Add secret** →
446
- store delete old) when Phase 1 decides where it lives.
447
-
448
- Per-brand next step for `sitedesk-code`: start **brand/consent-screen verification** to lift
449
- the 100-test-user cap and the 7-day re-consent measured above. That review is the slow part;
450
- everything else is minutes.
345
+ | `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. |
346
+ | `sitedesk-code` | Desktop app | `http://127.0.0.1` | *(not yet created)* | Awaiting provisioning in the **existing** `Sitedesk Calendar` project. |
347
+
348
+ No brand currently ships a populated `googleClientId`/`googleClientSecret`; the
349
+ plugin is inert everywhere until one does. Per-brand next step for
350
+ `sitedesk-code`: create the Desktop client per the runbook above, then start
351
+ **brand/consent-screen verification** to lift the 100-test-user cap and the
352
+ 7-day re-consent. That review is the slow part; everything else is minutes.