@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.
- package/dist/__tests__/google-client-id-env.test.js +3 -2
- package/package.json +1 -1
- package/payload/platform/neo4j/schema.cypher +4 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +29 -16
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/google-guide.md +25 -12
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +2 -2
- package/payload/platform/plugins/google/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/google/PLUGIN.md +73 -14
- package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.d.ts +2 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.js +229 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js +9 -9
- package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js.map +1 -1
- package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.d.ts +2 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.js +17 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.d.ts +2 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.js +146 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.d.ts +2 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.js +19 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.d.ts +2 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.js +41 -0
- package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/auth/account-audit.d.ts +44 -0
- package/payload/platform/plugins/google/mcp/dist/auth/account-audit.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/auth/account-audit.js +112 -0
- package/payload/platform/plugins/google/mcp/dist/auth/account-audit.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.d.ts +17 -0
- package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.js +143 -0
- package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/auth/pending-store.d.ts +9 -0
- package/payload/platform/plugins/google/mcp/dist/auth/pending-store.d.ts.map +1 -1
- package/payload/platform/plugins/google/mcp/dist/auth/pending-store.js.map +1 -1
- package/payload/platform/plugins/google/mcp/dist/index.js +73 -5
- package/payload/platform/plugins/google/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.d.ts +2 -0
- package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.d.ts.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.js +37 -0
- package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.js.map +1 -0
- package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts +20 -4
- package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts.map +1 -1
- package/payload/platform/plugins/google/mcp/dist/tools/account-register.js +19 -9
- package/payload/platform/plugins/google/mcp/dist/tools/account-register.js.map +1 -1
- package/payload/platform/plugins/google/references/auth.md +331 -367
- package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +3 -3
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.d.ts +14 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.js +15 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/voice-corpus.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +3 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +51 -4
- package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +4 -0
- package/payload/platform/scripts/backfill-task-ids.sh +206 -0
- package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +21 -3
- package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/activity-rows.js +6 -2
- package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +43 -5
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +9 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +78 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
- package/payload/server/public/activity.html +6 -6
- package/payload/server/public/assets/{AdminLoginScreens-B7TQZV8u.js → AdminLoginScreens-B44KbEuC.js} +1 -1
- package/payload/server/public/assets/{AdminShell-DAuri4Yh.js → AdminShell-BjxV2qkb.js} +2 -2
- package/payload/server/public/assets/{Checkbox-B1XhnhPk.js → Checkbox-DSeZNGNA.js} +1 -1
- package/payload/server/public/assets/activity-Mz1BhgB1.js +1 -0
- package/payload/server/public/assets/{admin-BdcccSb5.js → admin-B4LpTCur.js} +1 -1
- package/payload/server/public/assets/{browser-B6woQ2qX.js → browser-CEFvaWwI.js} +1 -1
- package/payload/server/public/assets/{calendar-CalCeg5T.js → calendar-B3zo6V34.js} +1 -1
- package/payload/server/public/assets/chat-5tJdo9i_.js +1 -0
- package/payload/server/public/assets/chevron-left-BJllrPBj.js +1 -0
- package/payload/server/public/assets/data-Du79EgR-.js +1 -0
- package/payload/server/public/assets/{graph-HeZaatSQ.js → graph-RaOrnSf2.js} +1 -1
- package/payload/server/public/assets/{graph-labels-4W7JuOgG.js → graph-labels-GlTEIYJj.js} +1 -1
- package/payload/server/public/assets/{maximize-2-DKx-aTPb.js → maximize-2-CHDa8p5h.js} +1 -1
- package/payload/server/public/assets/{operator-DD9L2VJM.js → operator-DtqHY4jV.js} +1 -1
- package/payload/server/public/assets/{page-tJxw0zNO.js → page-CkhFj8GQ.js} +1 -1
- package/payload/server/public/assets/{page-Ceado9Kr.js → page-DxnWgPtd.js} +1 -1
- package/payload/server/public/assets/{public-BYFWxyeC.js → public-D5TUfQ3I.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-DrzdV9h7.js → rotate-ccw-BJjVAQQY.js} +1 -1
- package/payload/server/public/assets/tasks-CM9pYYG1.js +1 -0
- package/payload/server/public/assets/{time-entry-format-Bn79w6tq.js → time-entry-format-C5RYEYjM.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-CjDVh6r-.js → triangle-alert-DLfIsuo_.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-DXHkkJw-.js → useCopyFeedback-7yKeiAzV.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-D2H8jAhE.js → useSelectionMode-CWlh6AEq.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-Br7Z6Cpu.css → useSubAccountSwitcher-B2n9QPVI.css} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-zUvPe55I.js → useVoiceRecorder-j3ZArZ2H.js} +1 -1
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +12 -12
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +11 -11
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +12 -12
- package/payload/server/public/tasks.html +5 -5
- package/payload/server/server.js +433 -313
- package/payload/server/public/assets/activity-kRwzxEKU.js +0 -1
- package/payload/server/public/assets/chat-9s2-L06v.js +0 -1
- package/payload/server/public/assets/chevron-left-C3ho8SnB.js +0 -1
- package/payload/server/public/assets/data-Cfr-7ERI.js +0 -1
- package/payload/server/public/assets/tasks-DSwoxGzh.js +0 -1
- /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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
##
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
33
|
+
```jsonc
|
|
34
|
+
// brands/<brand>/brand.json
|
|
35
|
+
"googleClientId": "<the client id>",
|
|
36
|
+
"googleClientSecret": "<the client secret>"
|
|
37
|
+
```
|
|
86
38
|
|
|
87
|
-
**
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
43
|
+
### Pre-flight: confirm the client accepts loopback
|
|
94
44
|
|
|
95
|
-
The
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
57
|
+
Sends the client id only, never the secret. Non-mutating.
|
|
111
58
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
132
|
+
Either reading `false` on a brand that ships both is the mis-stamp signature.
|
|
194
133
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
##
|
|
173
|
+
## Why this shape
|
|
204
174
|
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
247
|
-
their own app). Unlike Intuit, Google demands app
|
|
248
|
-
sensitive Calendar scope — homepage, privacy policy,
|
|
249
|
-
video — which the target customer cannot produce.
|
|
250
|
-
- **A vendor-hosted exchange broker
|
|
251
|
-
|
|
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`;
|
|
269
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
249
|
+
---
|
|
281
250
|
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
272
|
+
## Verification: the light kind, not the security assessment
|
|
336
273
|
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
###
|
|
297
|
+
### Recorded round-trip (2026-07-18)
|
|
356
298
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
317
|
+
## Token store and pending-state layout
|
|
378
318
|
|
|
379
|
-
|
|
380
|
-
account, `tokens.enc` under `accounts/<id>/`, mode 0700 dirs and 0600 files,
|
|
381
|
-
temp+rename writes.
|
|
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
|
|
328
|
+
└── pending-oauth.enc # transient: PKCE verifier between register and submit
|
|
390
329
|
```
|
|
391
330
|
|
|
392
|
-
Two divergences from Outlook are
|
|
331
|
+
Two divergences from Outlook are load-bearing:
|
|
393
332
|
|
|
394
|
-
- **Pending state is a PKCE verifier
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
- **The concurrent-refresh race
|
|
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
|
|
401
|
-
valid tokens on an IO error. A losing concurrent writer must not wipe
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
##
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
394
|
+
```
|
|
395
|
+
~/<brand>-code/platform/scripts/logs-read.sh --tail server 200 \
|
|
396
|
+
| grep -E '\[google-oauth\]|\[google-account-audit\]'
|
|
397
|
+
```
|
|
450
398
|
|
|
451
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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.
|